feat: add endpoint to delete board agents and implement related service logic

This commit is contained in:
Abhimanyu Saharan
2026-02-12 02:54:56 +05:30
parent bfcb695c6f
commit bd1e599ae5
3 changed files with 224 additions and 1 deletions

View File

@@ -550,6 +550,23 @@ async def update_agent_soul(
return OkResponse()
@router.delete("/boards/{board_id}/agents/{agent_id}", response_model=OkResponse)
async def delete_board_agent(
agent_id: str,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""Delete a board agent as the board lead."""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
service = AgentLifecycleService(session)
return await service.delete_agent_as_lead(
agent_id=agent_id,
actor_agent=agent_ctx.agent,
)
@router.post(
"/boards/{board_id}/gateway/main/ask-user",
response_model=GatewayMainAskUserResponse,

View File

@@ -1658,7 +1658,38 @@ class AgentLifecycleService(OpenClawDBService):
if agent is None:
return OkResponse()
await self.require_agent_access(agent=agent, ctx=ctx, write=True)
return await self._delete_agent_record(agent=agent)
async def delete_agent_as_lead(
self,
*,
agent_id: str,
actor_agent: Agent,
) -> OkResponse:
"""Delete a board-scoped agent as the board lead."""
self.logger.log(TRACE_LEVEL, "agent.delete.lead.start agent_id=%s", agent_id)
lead = OpenClawAuthorizationPolicy.require_board_lead_actor(
actor_agent=actor_agent,
detail="Only board leads can delete agents",
)
agent = await Agent.objects.by_id(agent_id).first(self.session)
if agent is None:
return OkResponse()
if agent.board_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads cannot delete gateway main agents",
)
board = await self.require_board(lead.board_id)
OpenClawAuthorizationPolicy.require_board_agent_target(target=agent, board=board)
if agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads cannot delete lead agents",
)
return await self._delete_agent_record(agent=agent)
async def _delete_agent_record(self, *, agent: Agent) -> OkResponse:
gateway: Gateway | None = None
client_config: GatewayClientConfig | None = None
workspace_path: str | None = None
@@ -1772,5 +1803,5 @@ class AgentLifecycleService(OpenClawDBService):
)
except (OSError, OpenClawGatewayError, ValueError):
pass
self.logger.info("agent.delete.success agent_id=%s", agent_id)
self.logger.info("agent.delete.success agent_id=%s", agent.id)
return OkResponse()

View File

@@ -0,0 +1,175 @@
# ruff: noqa: S101
"""Unit tests for lead-agent delete behavior."""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException, status
import app.services.openclaw.provisioning_db as agent_service
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
@dataclass
class _FakeSession:
committed: int = 0
deleted: list[object] = field(default_factory=list)
def add(self, _value: object) -> None:
return None
async def commit(self) -> None:
self.committed += 1
async def delete(self, value: object) -> None:
self.deleted.append(value)
@dataclass
class _AgentStub:
id: UUID
name: str
gateway_id: UUID
board_id: UUID | None = None
is_board_lead: bool = False
@dataclass
class _BoardStub:
id: UUID
gateway_id: UUID
@dataclass
class _GatewayStub:
id: UUID
url: str
token: str | None
workspace_root: str
@pytest.mark.asyncio
async def test_delete_agent_as_lead_removes_board_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _FakeSession()
service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type]
gateway_id = uuid4()
board = _BoardStub(id=uuid4(), gateway_id=gateway_id)
lead = _AgentStub(
id=uuid4(),
name="Lead Agent",
gateway_id=gateway_id,
board_id=board.id,
is_board_lead=True,
)
target = _AgentStub(
id=uuid4(),
name="Worker Agent",
gateway_id=gateway_id,
board_id=board.id,
is_board_lead=False,
)
async def _fake_first_agent(_session: object) -> _AgentStub:
return target
monkeypatch.setattr(
agent_service.Agent,
"objects",
SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)),
)
async def _fake_require_board(_board_id: object, **_kwargs: object) -> _BoardStub:
return board
async def _fake_require_gateway(
_board: object,
) -> tuple[_GatewayStub, GatewayClientConfig]:
gateway = _GatewayStub(
id=gateway_id,
url="ws://gateway.example/ws",
token=None,
workspace_root="/tmp/openclaw",
)
return gateway, GatewayClientConfig(url=gateway.url, token=None)
async def _fake_delete_agent_lifecycle(
_self,
*,
agent: object,
gateway: object,
delete_files: bool = True,
delete_session: bool = True,
) -> str | None:
_ = (_self, agent, gateway, delete_files, delete_session)
return None
async def _fake_update_where(*_args, **_kwargs) -> None:
return None
monkeypatch.setattr(service, "require_board", _fake_require_board)
monkeypatch.setattr(service, "require_gateway", _fake_require_gateway)
monkeypatch.setattr(
agent_service.OpenClawGatewayProvisioner,
"delete_agent_lifecycle",
_fake_delete_agent_lifecycle,
)
monkeypatch.setattr(agent_service.crud, "update_where", _fake_update_where)
monkeypatch.setattr(agent_service, "record_activity", lambda *_a, **_k: None)
result = await service.delete_agent_as_lead(
agent_id=str(target.id),
actor_agent=lead, # type: ignore[arg-type]
)
assert result.ok is True
assert session.deleted and session.deleted[0] == target
@pytest.mark.asyncio
async def test_delete_agent_as_lead_rejects_gateway_main(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _FakeSession()
service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type]
gateway_id = uuid4()
board_id = uuid4()
lead = _AgentStub(
id=uuid4(),
name="Lead Agent",
gateway_id=gateway_id,
board_id=board_id,
is_board_lead=True,
)
target = _AgentStub(
id=uuid4(),
name="Gateway Main",
gateway_id=gateway_id,
board_id=None,
is_board_lead=False,
)
async def _fake_first_agent(_session: object) -> _AgentStub:
return target
monkeypatch.setattr(
agent_service.Agent,
"objects",
SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)),
)
with pytest.raises(HTTPException) as exc_info:
await service.delete_agent_as_lead(
agent_id=str(target.id),
actor_agent=lead, # type: ignore[arg-type]
)
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
assert "gateway main" in str(exc_info.value.detail).lower()