feat(agents): Add heartbeat configuration and delete confirmation for agents
This commit is contained in:
@@ -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")
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
119
backend/app/integrations/openclaw_gateway_protocol.py
Normal file
119
backend/app/integrations/openclaw_gateway_protocol.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user