feat: enhance agent and board APIs with role-based tags and improved documentation

This commit is contained in:
Abhimanyu Saharan
2026-02-13 02:08:30 +05:30
parent 5695c0003d
commit a3148baca9
13 changed files with 550 additions and 280 deletions

View File

@@ -2,7 +2,9 @@
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Any
from typing import cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -77,6 +79,11 @@ TASK_STATUS_QUERY = Query(default=None, alias="status")
IS_CHAT_QUERY = Query(default=None)
APPROVAL_STATUS_QUERY = Query(default=None, alias="status")
AGENT_LEAD_TAGS = cast("list[str | Enum]", ["agent-lead"])
AGENT_MAIN_TAGS = cast("list[str | Enum]", ["agent-main"])
AGENT_BOARD_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
AGENT_ALL_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker", "agent-main"])
def _coerce_agent_items(items: Sequence[Any]) -> list[Agent]:
agents: list[Agent] = []
@@ -142,12 +149,20 @@ def _guard_task_access(agent_ctx: AgentAuthContext, task: Task) -> None:
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
@router.get(
"/boards",
response_model=DefaultLimitOffsetPage[BoardRead],
tags=AGENT_ALL_ROLE_TAGS,
)
async def list_boards(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[BoardRead]:
"""List boards visible to the authenticated agent."""
"""List boards visible to the authenticated agent.
Board-scoped agents typically see only their assigned board.
Main agents may see multiple boards when permitted by auth scope.
"""
statement = select(Board)
if agent_ctx.agent.board_id:
statement = statement.where(col(Board.id) == agent_ctx.agent.board_id)
@@ -155,23 +170,34 @@ async def list_boards(
return await paginate(session, statement)
@router.get("/boards/{board_id}", response_model=BoardRead)
@router.get("/boards/{board_id}", response_model=BoardRead, tags=AGENT_ALL_ROLE_TAGS)
def get_board(
board: Board = BOARD_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> Board:
"""Return a board if the authenticated agent can access it."""
"""Return one board if the authenticated agent can access it.
Use this when an agent needs board metadata (objective, status, target date)
before planning or posting updates.
"""
_guard_board_access(agent_ctx, board)
return board
@router.get("/agents", response_model=DefaultLimitOffsetPage[AgentRead])
@router.get(
"/agents",
response_model=DefaultLimitOffsetPage[AgentRead],
tags=AGENT_ALL_ROLE_TAGS,
)
async def list_agents(
board_id: UUID | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[AgentRead]:
"""List agents, optionally filtered to a board."""
"""List agents visible to the caller, optionally filtered by board.
Useful for lead delegation and workload balancing.
"""
statement = select(Agent)
if agent_ctx.agent.board_id:
if board_id:
@@ -195,14 +221,23 @@ async def list_agents(
return await paginate(session, statement, transformer=_transform)
@router.get("/boards/{board_id}/tasks", response_model=DefaultLimitOffsetPage[TaskRead])
@router.get(
"/boards/{board_id}/tasks",
response_model=DefaultLimitOffsetPage[TaskRead],
tags=AGENT_BOARD_TAGS,
)
async def list_tasks(
filters: AgentTaskListFilters = TASK_LIST_FILTERS_DEP,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[TaskRead]:
"""List tasks on a board with optional status and assignment filters."""
"""List tasks on a board with status/assignment filters.
Common patterns:
- worker: fetch assigned inbox/in-progress tasks
- lead: fetch unassigned inbox tasks for delegation
"""
_guard_board_access(agent_ctx, board)
return await tasks_api.list_tasks(
status_filter=filters.status_filter,
@@ -214,13 +249,16 @@ async def list_tasks(
)
@router.get("/boards/{board_id}/tags", response_model=list[TagRef])
@router.get("/boards/{board_id}/tags", response_model=list[TagRef], tags=AGENT_BOARD_TAGS)
async def list_tags(
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> list[TagRef]:
"""List tags available to the board's organization."""
"""List available tags for the board's organization.
Use returned ids in task create/update payloads (`tag_ids`).
"""
_guard_board_access(agent_ctx, board)
tags = (
await session.exec(
@@ -240,14 +278,18 @@ async def list_tags(
]
@router.post("/boards/{board_id}/tasks", response_model=TaskRead)
@router.post("/boards/{board_id}/tasks", response_model=TaskRead, tags=AGENT_LEAD_TAGS)
async def create_task(
payload: TaskCreate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> TaskRead:
"""Create a task on the board as the lead agent."""
"""Create a task as the board lead.
Lead-only endpoint. Supports dependency-aware creation via
`depends_on_task_ids` and optional `tag_ids`.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
data = payload.model_dump(exclude={"depends_on_task_ids", "tag_ids"})
@@ -343,14 +385,21 @@ async def create_task(
)
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
@router.patch(
"/boards/{board_id}/tasks/{task_id}",
response_model=TaskRead,
tags=AGENT_BOARD_TAGS,
)
async def update_task(
payload: TaskUpdate,
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> TaskRead:
"""Update a task after board-level access checks."""
"""Update a task after board-level authorization checks.
Supports status, assignment, dependencies, and optional inline comment.
"""
_guard_task_access(agent_ctx, task)
return await tasks_api.update_task(
payload=payload,
@@ -363,13 +412,17 @@ async def update_task(
@router.get(
"/boards/{board_id}/tasks/{task_id}/comments",
response_model=DefaultLimitOffsetPage[TaskCommentRead],
tags=AGENT_BOARD_TAGS,
)
async def list_task_comments(
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[TaskCommentRead]:
"""List comments for a task visible to the authenticated agent."""
"""List task comments visible to the authenticated agent.
Read this before posting updates to avoid duplicate or low-value comments.
"""
_guard_task_access(agent_ctx, task)
return await tasks_api.list_task_comments(
task=task,
@@ -380,6 +433,7 @@ async def list_task_comments(
@router.post(
"/boards/{board_id}/tasks/{task_id}/comments",
response_model=TaskCommentRead,
tags=AGENT_BOARD_TAGS,
)
async def create_task_comment(
payload: TaskCommentCreate,
@@ -387,7 +441,10 @@ async def create_task_comment(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> ActivityEvent:
"""Create a task comment on behalf of the authenticated agent."""
"""Create a task comment as the authenticated agent.
This is the primary collaboration/log surface for task progress.
"""
_guard_task_access(agent_ctx, task)
return await tasks_api.create_task_comment(
payload=payload,
@@ -400,6 +457,7 @@ async def create_task_comment(
@router.get(
"/boards/{board_id}/memory",
response_model=DefaultLimitOffsetPage[BoardMemoryRead],
tags=AGENT_BOARD_TAGS,
)
async def list_board_memory(
is_chat: bool | None = IS_CHAT_QUERY,
@@ -407,7 +465,10 @@ async def list_board_memory(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[BoardMemoryRead]:
"""List board memory entries with optional chat filtering."""
"""List board memory with optional chat filtering.
Use `is_chat=false` for durable context and `is_chat=true` for board chat.
"""
_guard_board_access(agent_ctx, board)
return await board_memory_api.list_board_memory(
is_chat=is_chat,
@@ -417,14 +478,17 @@ async def list_board_memory(
)
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead)
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead, tags=AGENT_BOARD_TAGS)
async def create_board_memory(
payload: BoardMemoryCreate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> BoardMemory:
"""Create a board memory entry."""
"""Create a board memory entry.
Use tags to indicate purpose (e.g. `chat`, `decision`, `plan`, `handoff`).
"""
_guard_board_access(agent_ctx, board)
return await board_memory_api.create_board_memory(
payload=payload,
@@ -437,6 +501,7 @@ async def create_board_memory(
@router.get(
"/boards/{board_id}/approvals",
response_model=DefaultLimitOffsetPage[ApprovalRead],
tags=AGENT_BOARD_TAGS,
)
async def list_approvals(
status_filter: ApprovalStatus | None = APPROVAL_STATUS_QUERY,
@@ -444,7 +509,10 @@ async def list_approvals(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[ApprovalRead]:
"""List approvals for a board."""
"""List approvals for a board.
Use status filtering to process pending approvals efficiently.
"""
_guard_board_access(agent_ctx, board)
return await approvals_api.list_approvals(
status_filter=status_filter,
@@ -454,14 +522,17 @@ async def list_approvals(
)
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead)
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead, tags=AGENT_BOARD_TAGS)
async def create_approval(
payload: ApprovalCreate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> ApprovalRead:
"""Create a board approval request."""
"""Create an approval request for risky or low-confidence actions.
Include `task_id` or `task_ids` to scope the decision precisely.
"""
_guard_board_access(agent_ctx, board)
return await approvals_api.create_approval(
payload=payload,
@@ -471,14 +542,21 @@ async def create_approval(
)
@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead)
@router.post(
"/boards/{board_id}/onboarding",
response_model=BoardOnboardingRead,
tags=AGENT_BOARD_TAGS,
)
async def update_onboarding(
payload: BoardOnboardingAgentUpdate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> BoardOnboardingSession:
"""Apply onboarding updates for a board."""
"""Apply board onboarding updates from an agent workflow.
Used during structured objective/success-metric intake loops.
"""
_guard_board_access(agent_ctx, board)
return await onboarding_api.agent_onboarding_update(
payload=payload,
@@ -488,13 +566,16 @@ async def update_onboarding(
)
@router.post("/agents", response_model=AgentRead)
@router.post("/agents", response_model=AgentRead, tags=AGENT_LEAD_TAGS)
async def create_agent(
payload: AgentCreate,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentRead:
"""Create an agent on the caller's board."""
"""Create a new board agent as lead.
The new agent is always forced onto the caller's board (`board_id` override).
"""
lead = _require_board_lead(agent_ctx)
payload = AgentCreate(
**{**payload.model_dump(), "board_id": lead.board_id},
@@ -506,7 +587,11 @@ async def create_agent(
)
@router.post("/boards/{board_id}/agents/{agent_id}/nudge", response_model=OkResponse)
@router.post(
"/boards/{board_id}/agents/{agent_id}/nudge",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
)
async def nudge_agent(
payload: AgentNudge,
agent_id: str,
@@ -514,7 +599,10 @@ async def nudge_agent(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""Send a direct nudge message to a board agent."""
"""Send a direct nudge to one board agent.
Lead-only endpoint for stale or blocked in-progress work.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
coordination = GatewayCoordinationService(session)
@@ -528,13 +616,16 @@ async def nudge_agent(
return OkResponse()
@router.post("/heartbeat", response_model=AgentRead)
@router.post("/heartbeat", response_model=AgentRead, tags=AGENT_ALL_ROLE_TAGS)
async def agent_heartbeat(
payload: AgentHeartbeatCreate,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentRead:
"""Record heartbeat status for the authenticated agent."""
"""Record heartbeat status for the authenticated agent.
Heartbeats are identity-bound to the token's agent id.
"""
# Heartbeats must apply to the authenticated agent; agent names are not unique.
return await agents_api.heartbeat_agent(
agent_id=str(agent_ctx.agent.id),
@@ -544,14 +635,21 @@ async def agent_heartbeat(
)
@router.get("/boards/{board_id}/agents/{agent_id}/soul", response_model=str)
@router.get(
"/boards/{board_id}/agents/{agent_id}/soul",
response_model=str,
tags=AGENT_BOARD_TAGS,
)
async def get_agent_soul(
agent_id: str,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> str:
"""Fetch the target agent's SOUL.md content from the gateway."""
"""Fetch an agent's SOUL.md content.
Allowed for board lead, or for an agent reading its own SOUL.
"""
_guard_board_access(agent_ctx, board)
OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
actor_agent=agent_ctx.agent,
@@ -565,7 +663,11 @@ async def get_agent_soul(
)
@router.put("/boards/{board_id}/agents/{agent_id}/soul", response_model=OkResponse)
@router.put(
"/boards/{board_id}/agents/{agent_id}/soul",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
)
async def update_agent_soul(
agent_id: str,
payload: SoulUpdateRequest,
@@ -573,7 +675,10 @@ async def update_agent_soul(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""Update an agent's SOUL.md content in DB and gateway."""
"""Update an agent's SOUL.md template in DB and gateway.
Lead-only endpoint. Persists as `soul_template` for future reprovisioning.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
coordination = GatewayCoordinationService(session)
@@ -589,14 +694,21 @@ async def update_agent_soul(
return OkResponse()
@router.delete("/boards/{board_id}/agents/{agent_id}", response_model=OkResponse)
@router.delete(
"/boards/{board_id}/agents/{agent_id}",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
)
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."""
"""Delete a board agent as board lead.
Cleans up runtime/session state through lifecycle services.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
service = AgentLifecycleService(session)
@@ -609,6 +721,7 @@ async def delete_board_agent(
@router.post(
"/boards/{board_id}/gateway/main/ask-user",
response_model=GatewayMainAskUserResponse,
tags=AGENT_LEAD_TAGS,
)
async def ask_user_via_gateway_main(
payload: GatewayMainAskUserRequest,
@@ -616,7 +729,10 @@ async def ask_user_via_gateway_main(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayMainAskUserResponse:
"""Route a lead's ask-user request through the dedicated gateway agent."""
"""Ask the human via gateway-main external channels.
Lead-only endpoint for situations where board chat is not responsive.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
coordination = GatewayCoordinationService(session)
@@ -630,6 +746,7 @@ async def ask_user_via_gateway_main(
@router.post(
"/gateway/boards/{board_id}/lead/message",
response_model=GatewayLeadMessageResponse,
tags=AGENT_MAIN_TAGS,
)
async def message_gateway_board_lead(
board_id: UUID,
@@ -637,7 +754,7 @@ async def message_gateway_board_lead(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadMessageResponse:
"""Send a gateway-main message to a single board lead agent."""
"""Send a gateway-main control message to one board lead."""
coordination = GatewayCoordinationService(session)
return await coordination.message_gateway_board_lead(
actor_agent=agent_ctx.agent,
@@ -649,13 +766,14 @@ async def message_gateway_board_lead(
@router.post(
"/gateway/leads/broadcast",
response_model=GatewayLeadBroadcastResponse,
tags=AGENT_MAIN_TAGS,
)
async def broadcast_gateway_lead_message(
payload: GatewayLeadBroadcastRequest,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadBroadcastResponse:
"""Broadcast a gateway-main message to multiple board leads."""
"""Broadcast a gateway-main control message to multiple board leads."""
coordination = GatewayCoordinationService(session)
return await coordination.broadcast_gateway_lead_message(
actor_agent=agent_ctx.agent,