feat(agents): Add heartbeat configuration and delete confirmation for agents

This commit is contained in:
Abhimanyu Saharan
2026-02-04 17:05:58 +05:30
parent ddad2ddb72
commit 8aa96ca876
16 changed files with 646 additions and 102 deletions

View File

@@ -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")

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -9,13 +9,13 @@ from sqlmodel import Session, col, select
from sqlalchemy import update from sqlalchemy import update
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 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 ( from app.integrations.openclaw_gateway import (
GatewayConfig, GatewayConfig,
OpenClawGatewayError, OpenClawGatewayError,
delete_session,
ensure_session, ensure_session,
send_message, send_message,
) )
@@ -24,13 +24,18 @@ from app.models.activity_events import ActivityEvent
from app.models.boards import Board from app.models.boards import Board
from app.schemas.agents import ( from app.schemas.agents import (
AgentCreate, AgentCreate,
AgentDeleteConfirm,
AgentHeartbeat, AgentHeartbeat,
AgentHeartbeatCreate, AgentHeartbeatCreate,
AgentRead, AgentRead,
AgentUpdate, AgentUpdate,
) )
from app.services.activity_log import record_activity 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"]) router = APIRouter(prefix="/agents", tags=["agents"])
@@ -82,6 +87,8 @@ async def _ensure_gateway_session(
def _with_computed_status(agent: Agent) -> Agent: def _with_computed_status(agent: Agent) -> Agent:
now = datetime.utcnow() now = datetime.utcnow()
if agent.status == "deleting":
return agent
if agent.last_seen_at is None: if agent.last_seen_at is None:
agent.status = "provisioning" agent.status = "provisioning"
elif now - agent.last_seen_at > OFFLINE_AFTER: 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( record_activity(
session, session,
event_type="agent.provision.failed", event_type=f"agent.{action}.failed",
message=f"Provisioning message failed: {error}", message=f"{action_label} message failed: {error}",
agent_id=agent.id, 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) session_key = agent.openclaw_session_id or _build_session_key(agent.name)
message = ( 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 " "Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup." "then delete it. Begin heartbeats after startup."
) )
@@ -147,6 +159,8 @@ async def create_agent(
agent.status = "provisioning" agent.status = "provisioning"
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)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session_key, session_error = await _ensure_gateway_session(agent.name, config) session_key, session_error = await _ensure_gateway_session(agent.name, config)
agent.openclaw_session_id = session_key agent.openclaw_session_id = session_key
session.add(agent) session.add(agent)
@@ -177,11 +191,11 @@ async def create_agent(
agent_id=agent.id, agent_id=agent.id,
) )
except OpenClawGatewayError as exc: 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
return agent return agent
@@ -222,6 +236,8 @@ async def update_agent(
for key, value in updates.items(): for key, value in updates.items():
setattr(agent, key, value) setattr(agent, key, value)
agent.updated_at = datetime.utcnow() agent.updated_at = datetime.utcnow()
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent) session.add(agent)
session.commit() session.commit()
session.refresh(agent) session.refresh(agent)
@@ -236,7 +252,7 @@ async def update_agent(
session.commit() session.commit()
session.refresh(agent) session.refresh(agent)
except OpenClawGatewayError as exc: except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc)) _record_instruction_failure(session, agent, str(exc), "update")
session.commit() session.commit()
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)
@@ -244,12 +260,12 @@ async def update_agent(
session.commit() session.commit()
session.refresh(agent) session.refresh(agent)
try: try:
await send_provisioning_message(agent, board, raw_token) await send_update_message(agent, board, raw_token)
await _send_wakeup_message(agent, config) await _send_wakeup_message(agent, config, verb="updated")
record_activity( record_activity(
session, session,
event_type="agent.reprovisioned", event_type="agent.updated",
message=f"Re-provisioned agent {agent.name}.", message=f"Updated agent {agent.name}.",
agent_id=agent.id, agent_id=agent.id,
) )
record_activity( record_activity(
@@ -260,11 +276,11 @@ async def update_agent(
) )
session.commit() session.commit()
except OpenClawGatewayError as exc: 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
return _with_computed_status(agent) return _with_computed_status(agent)
@@ -307,7 +323,12 @@ async def heartbeat_or_create_agent(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board = _require_board(session, payload.board_id) board = _require_board(session, payload.board_id)
config = _require_gateway_config(board) 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() raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token) agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name, config) 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, agent_id=agent.id,
) )
except OpenClawGatewayError as exc: 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: 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": elif agent.agent_token_hash is None and actor.actor_type == "user":
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)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
session.add(agent) session.add(agent)
session.commit() session.commit()
session.refresh(agent) session.refresh(agent)
@@ -367,11 +390,11 @@ async def heartbeat_or_create_agent(
agent_id=agent.id, agent_id=agent.id,
) )
except OpenClawGatewayError as exc: 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors 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)) _record_wakeup_failure(session, agent, str(exc))
session.commit() session.commit()
elif not agent.openclaw_session_id: elif not agent.openclaw_session_id:
@@ -414,51 +437,104 @@ def delete_agent(
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]: ) -> dict[str, bool]:
agent = session.get(Agent, agent_id) agent = session.get(Agent, agent_id)
if agent: if agent is None:
board = _require_board(session, str(agent.board_id) if agent.board_id else None) return {"ok": True}
config = _require_gateway_config(board) if agent.status == "deleting" and agent.delete_confirm_token_hash:
async def _gateway_cleanup() -> None: return {"ok": True}
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,
)
try: board = _require_board(session, str(agent.board_id) if agent.board_id else None)
import asyncio 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()) async def _gateway_cleanup_request() -> None:
except OpenClawGatewayError as exc: main_session = board.gateway_main_session_key or "agent:main:main"
raise HTTPException( if not main_session:
status_code=status.HTTP_502_BAD_GATEWAY, return
detail=f"Gateway cleanup failed: {exc}", workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
) from exc workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
session.execute( base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
update(ActivityEvent) cleanup_message = (
.where(col(ActivityEvent.agent_id) == agent.id) "Cleanup request for deleted agent.\n\n"
.values(agent_id=None) 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() 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} return {"ok": True}

View File

@@ -13,6 +13,11 @@ from app.integrations.openclaw_gateway import (
openclaw_call, openclaw_call,
send_message, send_message,
) )
from app.integrations.openclaw_gateway_protocol import (
GATEWAY_EVENTS,
GATEWAY_METHODS,
PROTOCOL_VERSION,
)
from app.db.session import get_session from app.db.session import get_session
from app.models.boards import Board from app.models.boards import Board
@@ -196,3 +201,14 @@ async def send_session_message(
except OpenClawGatewayError as exc: except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return {"ok": True} 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,
}

View File

@@ -9,6 +9,7 @@ from uuid import uuid4
import websockets import websockets
from app.integrations.openclaw_gateway_protocol import PROTOCOL_VERSION
class OpenClawGatewayError(RuntimeError): class OpenClawGatewayError(RuntimeError):
@@ -64,23 +65,10 @@ async def _send_request(
return await _await_response(ws, request_id) return await _await_response(ws, request_id)
async def _handle_challenge( def _build_connect_params(config: GatewayConfig) -> dict[str, Any]:
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())
params: dict[str, Any] = { params: dict[str, Any] = {
"minProtocol": 3, "minProtocol": PROTOCOL_VERSION,
"maxProtocol": 3, "maxProtocol": PROTOCOL_VERSION,
"client": { "client": {
"id": "gateway-client", "id": "gateway-client",
"version": "1.0.0", "version": "1.0.0",
@@ -90,11 +78,26 @@ async def _handle_challenge(
} }
if config.token: if config.token:
params["auth"] = {"token": 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 = { response = {
"type": "req", "type": "req",
"id": connect_id, "id": connect_id,
"method": "connect", "method": "connect",
"params": params, "params": _build_connect_params(config),
} }
await ws.send(json.dumps(response)) await ws.send(json.dumps(response))
await _await_response(ws, connect_id) await _await_response(ws, connect_id)
@@ -114,7 +117,7 @@ async def openclaw_call(
first_message = await asyncio.wait_for(ws.recv(), timeout=2) first_message = await asyncio.wait_for(ws.recv(), timeout=2)
except asyncio.TimeoutError: except asyncio.TimeoutError:
first_message = None first_message = None
await _handle_challenge(ws, first_message, config) await _ensure_connected(ws, first_message, config)
return await _send_request(ws, method, params) return await _send_request(ws, method, params)
except OpenClawGatewayError: except OpenClawGatewayError:
raise raise

View File

@@ -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

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import Column, JSON
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
@@ -15,6 +17,11 @@ class Agent(SQLModel, table=True):
status: str = Field(default="provisioning", index=True) status: str = Field(default="provisioning", index=True)
openclaw_session_id: str | None = Field(default=None, index=True) openclaw_session_id: str | None = Field(default=None, index=True)
agent_token_hash: 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) last_seen_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from uuid import UUID from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -10,6 +11,7 @@ class AgentBase(SQLModel):
board_id: UUID | None = None board_id: UUID | None = None
name: str name: str
status: str = "provisioning" status: str = "provisioning"
heartbeat_config: dict[str, Any] | None = None
class AgentCreate(AgentBase): class AgentCreate(AgentBase):
@@ -20,6 +22,7 @@ class AgentUpdate(SQLModel):
board_id: UUID | None = None board_id: UUID | None = None
name: str | None = None name: str | None = None
status: str | None = None status: str | None = None
heartbeat_config: dict[str, Any] | None = None
class AgentRead(AgentBase): class AgentRead(AgentBase):
@@ -37,3 +40,7 @@ class AgentHeartbeat(SQLModel):
class AgentHeartbeatCreate(AgentHeartbeat): class AgentHeartbeatCreate(AgentHeartbeat):
name: str name: str
board_id: UUID | None = None board_id: UUID | None = None
class AgentDeleteConfirm(SQLModel):
token: str

View File

@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import uuid4 from uuid import uuid4
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
@@ -22,6 +24,8 @@ TEMPLATE_FILES = [
"USER.md", "USER.md",
] ]
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
def _repo_root() -> Path: def _repo_root() -> Path:
return Path(__file__).resolve().parents[3] return Path(__file__).resolve().parents[3]
@@ -36,6 +40,21 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex 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: def _template_env() -> Environment:
return Environment( return Environment(
loader=FileSystemLoader(_templates_root()), loader=FileSystemLoader(_templates_root()),
@@ -69,15 +88,14 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
return f"{root}/{_slugify(agent_name)}" 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) agent_id = str(agent.id)
workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces" workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces"
workspace_path = _workspace_path(agent.name, workspace_root) workspace_path = _workspace_path(agent.name, workspace_root)
session_key = agent.openclaw_session_id or "" session_key = agent.openclaw_session_id or ""
base_url = settings.base_url or "REPLACE_WITH_BASE_URL" base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
main_session_key = board.gateway_main_session_key or "agent:main:main" main_session_key = board.gateway_main_session_key or "agent:main:main"
return {
context = {
"agent_name": agent.name, "agent_name": agent.name,
"agent_id": agent_id, "agent_id": agent_id,
"board_id": str(board.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.", "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 _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 ( return (
"Provision a new OpenClaw agent workspace.\n\n" "Provision a new OpenClaw agent workspace.\n\n"
f"Agent name: {agent.name}\n" f"Agent name: {context['agent_name']}\n"
f"Agent id: {agent_id}\n" f"Agent id: {context['agent_id']}\n"
f"Session key: {session_key}\n" f"Session key: {context['session_key']}\n"
f"Workspace path: {workspace_path}\n\n" f"Workspace path: {context['workspace_path']}\n\n"
f"Base URL: {base_url}\n" f"Base URL: {context['base_url']}\n"
f"Auth token: {auth_token}\n\n" f"Auth token: {context['auth_token']}\n\n"
"Steps:\n" "Steps:\n"
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep " "0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
f"{context['main_session_key']} unchanged and its workspace intact.\n" 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" "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" "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 " "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-<name>. The canonical path "
"is ~/.openclaw/workspaces/<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\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-<name>. The canonical path "
"is ~/.openclaw/workspaces/<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\n"
"Files:" + file_blocks "Files:" + file_blocks
) )
@@ -132,3 +213,17 @@ async def send_provisioning_message(
await ensure_session(main_session, config=config, label="Main Agent") await ensure_session(main_session, config=config, label="Main Agent")
message = build_provisioning_message(agent, board, auth_token) message = build_provisioning_message(agent, board, auth_token)
await send_message(message, session_key=main_session, config=config, deliver=False) 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)

View File

@@ -25,6 +25,10 @@ type Agent = {
id: string; id: string;
name: string; name: string;
board_id?: string | null; board_id?: string | null;
heartbeat_config?: {
every?: string;
target?: string;
} | null;
}; };
type Board = { type Board = {
@@ -44,6 +48,8 @@ export default function EditAgentPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(""); const [boardId, setBoardId] = useState("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -59,9 +65,6 @@ export default function EditAgentPage() {
} }
const data = (await response.json()) as Board[]; const data = (await response.json()) as Board[];
setBoards(data); setBoards(data);
if (!boardId && data.length > 0) {
setBoardId(data[0].id);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong."); setError(err instanceof Error ? err.message : "Something went wrong.");
} }
@@ -85,6 +88,12 @@ export default function EditAgentPage() {
if (data.board_id) { if (data.board_id) {
setBoardId(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) { } catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong."); setError(err instanceof Error ? err.message : "Something went wrong.");
} finally { } finally {
@@ -98,6 +107,17 @@ export default function EditAgentPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]); }, [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<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!isSignedIn || !agentId) return; if (!isSignedIn || !agentId) return;
@@ -120,7 +140,14 @@ export default function EditAgentPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "", 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) { if (!response.ok) {
throw new Error("Unable to update agent."); throw new Error("Unable to update agent.");
@@ -195,6 +222,38 @@ export default function EditAgentPage() {
</p> </p>
) : null} ) : null}
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-quiet">
Set how often this agent runs HEARTBEAT.md.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat target
</label>
<Select
value={heartbeatTarget}
onValueChange={(value) => setHeartbeatTarget(value)}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (no outbound message)</SelectItem>
<SelectItem value="last">Last channel</SelectItem>
</SelectContent>
</Select>
</div>
{error ? ( {error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted"> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error} {error}

View File

@@ -39,6 +39,8 @@ export default function NewAgentPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState<string>(""); const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
const [heartbeatTarget, setHeartbeatTarget] = useState("none");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -89,7 +91,14 @@ export default function NewAgentPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "", 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) { if (!response.ok) {
throw new Error("Unable to create agent."); throw new Error("Unable to create agent.");
@@ -165,6 +174,38 @@ export default function NewAgentPage() {
</p> </p>
) : null} ) : null}
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) => setHeartbeatEvery(event.target.value)}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-quiet">
Set how often this agent runs HEARTBEAT.md (e.g. 10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Heartbeat target
</label>
<Select
value={heartbeatTarget}
onValueChange={(value) => setHeartbeatTarget(value)}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (no outbound message)</SelectItem>
<SelectItem value="last">Last channel</SelectItem>
</SelectContent>
</Select>
</div>
{error ? ( {error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted"> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error} {error}

View File

@@ -204,7 +204,7 @@ export default function AgentsPage() {
if (!response.ok) { if (!response.ok) {
throw new Error("Unable to delete agent."); throw new Error("Unable to delete agent.");
} }
setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id)); await loadAgents();
setDeleteTarget(null); setDeleteTarget(null);
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : "Something went wrong."); setDeleteError(err instanceof Error ? err.message : "Something went wrong.");

View File

@@ -14,6 +14,7 @@ const STATUS_STYLES: Record<
busy: "warning", busy: "warning",
provisioning: "warning", provisioning: "warning",
offline: "outline", offline: "outline",
deleting: "danger",
}; };
export function StatusPill({ status }: { status: string }) { export function StatusPill({ status }: { status: string }) {

View File

@@ -9,7 +9,7 @@ If this file is empty, skip heartbeat work.
- BOARD_ID - BOARD_ID
## Schedule ## 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 first boot, send one immediate check-in before the schedule starts.
## On every heartbeat ## On every heartbeat