"""Thin API wrappers for async agent lifecycle operations.""" from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sse_starlette.sse import EventSourceResponse from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin from app.core.auth import AuthContext, get_auth_context from app.db.session import get_session from app.schemas.agents import ( AgentCreate, AgentHeartbeat, AgentHeartbeatCreate, AgentRead, AgentUpdate, ) from app.schemas.common import OkResponse from app.schemas.pagination import DefaultLimitOffsetPage from app.services.openclaw.agent_service import AgentLifecycleService, AgentUpdateOptions from app.services.organizations import OrganizationContext if TYPE_CHECKING: from fastapi_pagination.limit_offset import LimitOffsetPage from sqlmodel.ext.asyncio.session import AsyncSession router = APIRouter(prefix="/agents", tags=["agents"]) BOARD_ID_QUERY = Query(default=None) GATEWAY_ID_QUERY = Query(default=None) SINCE_QUERY = Query(default=None) SESSION_DEP = Depends(get_session) ORG_ADMIN_DEP = Depends(require_org_admin) ACTOR_DEP = Depends(require_admin_or_agent) AUTH_DEP = Depends(get_auth_context) @dataclass(frozen=True, slots=True) class _AgentUpdateParams: force: bool auth: AuthContext ctx: OrganizationContext def _agent_update_params( *, force: bool = False, auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> _AgentUpdateParams: return _AgentUpdateParams(force=force, auth=auth, ctx=ctx) AGENT_UPDATE_PARAMS_DEP = Depends(_agent_update_params) @router.get("", response_model=DefaultLimitOffsetPage[AgentRead]) async def list_agents( board_id: UUID | None = BOARD_ID_QUERY, gateway_id: UUID | None = GATEWAY_ID_QUERY, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> LimitOffsetPage[AgentRead]: """List agents visible to the active organization admin.""" service = AgentLifecycleService(session) return await service.list_agents( board_id=board_id, gateway_id=gateway_id, ctx=ctx, ) @router.get("/stream") async def stream_agents( request: Request, board_id: UUID | None = BOARD_ID_QUERY, since: str | None = SINCE_QUERY, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> EventSourceResponse: """Stream agent updates as SSE events.""" service = AgentLifecycleService(session) return await service.stream_agents( request=request, board_id=board_id, since=since, ctx=ctx, ) @router.post("", response_model=AgentRead) async def create_agent( payload: AgentCreate, session: AsyncSession = SESSION_DEP, actor: ActorContext = ACTOR_DEP, ) -> AgentRead: """Create and provision an agent.""" service = AgentLifecycleService(session) return await service.create_agent(payload=payload, actor=actor) @router.get("/{agent_id}", response_model=AgentRead) async def get_agent( agent_id: str, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> AgentRead: """Get a single agent by id.""" service = AgentLifecycleService(session) return await service.get_agent(agent_id=agent_id, ctx=ctx) @router.patch("/{agent_id}", response_model=AgentRead) async def update_agent( agent_id: str, payload: AgentUpdate, params: _AgentUpdateParams = AGENT_UPDATE_PARAMS_DEP, session: AsyncSession = SESSION_DEP, ) -> AgentRead: """Update agent metadata and optionally reprovision.""" service = AgentLifecycleService(session) return await service.update_agent( agent_id=agent_id, payload=payload, options=AgentUpdateOptions( force=params.force, user=params.auth.user, context=params.ctx, ), ) @router.post("/{agent_id}/heartbeat", response_model=AgentRead) async def heartbeat_agent( agent_id: str, payload: AgentHeartbeat, session: AsyncSession = SESSION_DEP, actor: ActorContext = ACTOR_DEP, ) -> AgentRead: """Record a heartbeat for a specific agent.""" service = AgentLifecycleService(session) return await service.heartbeat_agent(agent_id=agent_id, payload=payload, actor=actor) @router.post("/heartbeat", response_model=AgentRead) async def heartbeat_or_create_agent( payload: AgentHeartbeatCreate, session: AsyncSession = SESSION_DEP, actor: ActorContext = ACTOR_DEP, ) -> AgentRead: """Heartbeat an existing agent or create/provision one if needed.""" service = AgentLifecycleService(session) return await service.heartbeat_or_create_agent(payload=payload, actor=actor) @router.delete("/{agent_id}", response_model=OkResponse) async def delete_agent( agent_id: str, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> OkResponse: """Delete an agent and clean related task state.""" service = AgentLifecycleService(session) return await service.delete_agent(agent_id=agent_id, ctx=ctx)