160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
"""Helpers for ensuring each board has a provisioned lead agent."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from sqlmodel import col, select
|
|
|
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
|
from app.core.time import utcnow
|
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
|
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
|
from app.models.agents import Agent
|
|
from app.services.agent_provisioning import (
|
|
DEFAULT_HEARTBEAT_CONFIG,
|
|
AgentProvisionRequest,
|
|
ProvisionOptions,
|
|
provision_agent,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.models.boards import Board
|
|
from app.models.gateways import Gateway
|
|
from app.models.users import User
|
|
|
|
|
|
def lead_session_key(board: Board) -> str:
|
|
"""Return the deterministic main session key for a board lead agent."""
|
|
return f"agent:lead-{board.id}:main"
|
|
|
|
|
|
def lead_agent_name(_: Board) -> str:
|
|
"""Return the default display name for board lead agents."""
|
|
return "Lead Agent"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class LeadAgentOptions:
|
|
"""Optional overrides for board-lead provisioning behavior."""
|
|
|
|
agent_name: str | None = None
|
|
identity_profile: dict[str, str] | None = None
|
|
action: str = "provision"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class LeadAgentRequest:
|
|
"""Inputs required to ensure or provision a board lead agent."""
|
|
|
|
board: Board
|
|
gateway: Gateway
|
|
config: GatewayClientConfig
|
|
user: User | None
|
|
options: LeadAgentOptions = field(default_factory=LeadAgentOptions)
|
|
|
|
|
|
async def ensure_board_lead_agent(
|
|
session: AsyncSession,
|
|
*,
|
|
request: LeadAgentRequest,
|
|
) -> tuple[Agent, bool]:
|
|
"""Ensure a board has a lead agent; return `(agent, created)`."""
|
|
board = request.board
|
|
config_options = request.options
|
|
existing = (
|
|
await session.exec(
|
|
select(Agent)
|
|
.where(Agent.board_id == board.id)
|
|
.where(col(Agent.is_board_lead).is_(True)),
|
|
)
|
|
).first()
|
|
if existing:
|
|
desired_name = config_options.agent_name or lead_agent_name(board)
|
|
changed = False
|
|
if existing.name != desired_name:
|
|
existing.name = desired_name
|
|
changed = True
|
|
if existing.gateway_id != request.gateway.id:
|
|
existing.gateway_id = request.gateway.id
|
|
changed = True
|
|
desired_session_key = lead_session_key(board)
|
|
if not existing.openclaw_session_id:
|
|
existing.openclaw_session_id = desired_session_key
|
|
changed = True
|
|
if changed:
|
|
existing.updated_at = utcnow()
|
|
session.add(existing)
|
|
await session.commit()
|
|
await session.refresh(existing)
|
|
return existing, False
|
|
|
|
merged_identity_profile: dict[str, Any] = {
|
|
"role": "Board Lead",
|
|
"communication_style": "direct, concise, practical",
|
|
"emoji": ":gear:",
|
|
}
|
|
if config_options.identity_profile:
|
|
merged_identity_profile.update(
|
|
{
|
|
key: value.strip()
|
|
for key, value in config_options.identity_profile.items()
|
|
if value.strip()
|
|
},
|
|
)
|
|
|
|
agent = Agent(
|
|
name=config_options.agent_name or lead_agent_name(board),
|
|
status="provisioning",
|
|
board_id=board.id,
|
|
gateway_id=request.gateway.id,
|
|
is_board_lead=True,
|
|
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
|
|
identity_profile=merged_identity_profile,
|
|
openclaw_session_id=lead_session_key(board),
|
|
provision_requested_at=utcnow(),
|
|
provision_action=config_options.action,
|
|
)
|
|
raw_token = generate_agent_token()
|
|
agent.agent_token_hash = hash_agent_token(raw_token)
|
|
session.add(agent)
|
|
await session.commit()
|
|
await session.refresh(agent)
|
|
|
|
try:
|
|
await provision_agent(
|
|
agent,
|
|
AgentProvisionRequest(
|
|
board=board,
|
|
gateway=request.gateway,
|
|
auth_token=raw_token,
|
|
user=request.user,
|
|
options=ProvisionOptions(action=config_options.action),
|
|
),
|
|
)
|
|
if agent.openclaw_session_id:
|
|
await ensure_session(
|
|
agent.openclaw_session_id,
|
|
config=request.config,
|
|
label=agent.name,
|
|
)
|
|
await send_message(
|
|
(
|
|
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
|
|
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run "
|
|
"it once "
|
|
"then delete it. Begin heartbeats after startup."
|
|
),
|
|
session_key=agent.openclaw_session_id,
|
|
config=request.config,
|
|
deliver=True,
|
|
)
|
|
except OpenClawGatewayError:
|
|
# Best-effort provisioning. The board/agent rows should still exist.
|
|
pass
|
|
|
|
return agent, True
|