Files
openclaw-mission-control/backend/app/api/agent.py
Hugh Brown 4960d8561b security: fix fail-open auth, streaming payload limit, and rate limiter memory leak
- agent.py: Fail closed when gateway lookup returns None instead of
  silently dropping the organization filter (cross-tenant board leak)
- board_webhooks.py: Read request body via streaming chunks so an
  oversized payload is rejected before it is fully loaded into memory
- rate_limit.py: Add periodic sweep of expired keys to prevent
  unbounded memory growth from inactive clients
- test_rate_limit.py: Add test for the new sweep behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:35:10 +05:30

2036 lines
69 KiB
Python

"""Agent-scoped API routes for board operations and gateway coordination."""
from __future__ import annotations
import json
from enum import Enum
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlmodel import SQLModel, col, select
from app.api import agents as agents_api
from app.api import approvals as approvals_api
from app.api import board_memory as board_memory_api
from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_api
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.agents import Agent
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.tags import Tag
from app.models.task_dependencies import TaskDependency
from app.models.tasks import Task
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
AgentNudge,
AgentRead,
)
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
from app.schemas.board_webhooks import BoardWebhookPayloadRead
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse
from app.schemas.errors import LLMErrorResponse
from app.schemas.gateway_coordination import (
GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest,
GatewayLeadMessageResponse,
GatewayMainAskUserRequest,
GatewayMainAskUserResponse,
)
from app.schemas.health import AgentHealthStatusResponse
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tags import TagRef
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.openclaw.coordination_service import GatewayCoordinationService
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning_db import AgentLifecycleService
from app.services.tags import replace_tags, validate_tag_ids
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_status_by_id,
validate_dependency_update,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.activity_events import ActivityEvent
from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
router = APIRouter(prefix="/agent", tags=["agent"])
SESSION_DEP = Depends(get_session)
AGENT_CTX_DEP = Depends(get_agent_auth_context)
BOARD_DEP = Depends(get_board_or_404)
TASK_DEP = Depends(get_task_or_404)
BOARD_ID_QUERY = Query(default=None)
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] = []
for item in items:
if not isinstance(item, Agent):
msg = "Expected Agent items from paginated query"
raise TypeError(msg)
agents.append(item)
return agents
class SoulUpdateRequest(SQLModel):
"""Payload for updating an agent SOUL document."""
content: str
source_url: str | None = None
reason: str | None = None
class AgentTaskListFilters(SQLModel):
"""Query filters for board task listing in agent routes."""
status_filter: str | None = None
assigned_agent_id: UUID | None = None
unassigned: bool | None = None
def _task_list_filters(
status_filter: str | None = TASK_STATUS_QUERY,
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
) -> AgentTaskListFilters:
return AgentTaskListFilters(
status_filter=status_filter,
assigned_agent_id=assigned_agent_id,
unassigned=unassigned,
)
TASK_LIST_FILTERS_DEP = Depends(_task_list_filters)
def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
return ActorContext(actor_type="agent", agent=agent_ctx.agent)
def _agent_board_openapi_hints(
*,
intent: str,
when_to_use: list[str],
routing_examples: list[dict[str, object]],
required_actor: str = "any_agent",
when_not_to_use: list[str] | None = None,
routing_policy: list[str] | None = None,
negative_guidance: list[str] | None = None,
prerequisites: list[str] | None = None,
side_effects: list[str] | None = None,
) -> dict[str, object]:
return {
"x-llm-intent": intent,
"x-when-to-use": when_to_use,
"x-when-not-to-use": when_not_to_use
or [
"Use a more specific endpoint for direct state mutation or direct messaging.",
],
"x-required-actor": required_actor,
"x-prerequisites": prerequisites
or [
"Authenticated agent token",
"Board access is validated before execution",
],
"x-side-effects": side_effects or ["Read/write side effects vary by endpoint semantics."],
"x-negative-guidance": negative_guidance
or ["Avoid this endpoint when a focused sibling endpoint handles the action."],
"x-routing-policy": routing_policy
or [
"Use when the request intent matches this board-scoped route.",
"Prefer dedicated mutation/read routes once intent is narrowed.",
],
"x-routing-policy-examples": routing_examples,
}
def _truncate_preview(raw: str, max_chars: int) -> str:
if len(raw) <= max_chars:
return raw
if max_chars <= 3:
return raw[:max_chars]
return f"{raw[: max_chars - 3]}..."
def _payload_preview_with_limit(
value: dict[str, object] | list[object] | str | int | float | bool | None,
*,
max_chars: int,
) -> tuple[str, bool]:
if isinstance(value, str):
return _truncate_preview(value, max_chars), len(value) > max_chars
try:
# Stream JSON chunks so we can stop once we know truncation is required.
encoder = json.JSONEncoder(ensure_ascii=True)
parts: list[str] = []
current_len = 0
truncated = False
for chunk in encoder.iterencode(value):
remaining = (max_chars + 1) - current_len
if remaining <= 0:
truncated = True
break
if len(chunk) <= remaining:
parts.append(chunk)
current_len += len(chunk)
continue
parts.append(chunk[:remaining])
current_len += remaining
truncated = True
break
raw = "".join(parts)
except TypeError:
raw = str(value)
return _truncate_preview(raw, max_chars), len(raw) > max_chars
if len(raw) > max_chars:
truncated = True
if not truncated:
return raw, False
return _truncate_preview(raw, max_chars), True
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
allowed = not (agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id)
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
def _require_board_lead(agent_ctx: AgentAuthContext) -> Agent:
return OpenClawAuthorizationPolicy.require_board_lead_actor(
actor_agent=agent_ctx.agent,
detail="Only board leads can perform this action",
)
def _guard_task_access(agent_ctx: AgentAuthContext, task: Task) -> None:
allowed = not (
agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id
)
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
@router.get(
"/healthz",
response_model=AgentHealthStatusResponse,
tags=AGENT_ALL_ROLE_TAGS,
summary="Agent Auth Health Check",
description=(
"Token-authenticated liveness probe for agent API clients.\n\n"
"Use this endpoint when the caller needs to verify both service availability "
"and agent-token validity in one request."
),
openapi_extra={
"x-llm-intent": "agent_auth_health",
"x-when-to-use": [
"Verify agent token validity before entering an automation loop",
"Confirm agent API availability with caller identity context",
],
"x-when-not-to-use": [
"General infrastructure liveness checks that do not require auth context",
"Task, board, or messaging workflow actions",
],
"x-required-actor": "any_agent",
"x-prerequisites": [
"Authenticated agent token via X-Agent-Token header",
],
"x-side-effects": [
"May refresh agent last-seen presence metadata via auth middleware",
],
"x-negative-guidance": [
"Do not parse this response as an array.",
"Do not use this endpoint for task routing decisions.",
],
"x-routing-policy": [
"Use this as the first probe for agent-scoped automation health.",
"Use /healthz only for unauthenticated service-level liveness checks.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent startup probe with token verification",
"required_privilege": "any_agent",
},
"decision": "agent_auth_health",
},
{
"input": {
"intent": "platform-level probe with no agent token",
"required_privilege": "none",
},
"decision": "service_healthz",
},
],
},
)
def agent_healthz(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentHealthStatusResponse:
"""Return authenticated liveness metadata for the current agent token."""
return AgentHealthStatusResponse(
ok=True,
agent_id=agent_ctx.agent.id,
board_id=agent_ctx.agent.board_id,
gateway_id=agent_ctx.agent.gateway_id,
status=agent_ctx.agent.status,
is_board_lead=agent_ctx.agent.is_board_lead,
)
@router.get(
"/boards",
response_model=DefaultLimitOffsetPage[BoardRead],
tags=AGENT_ALL_ROLE_TAGS,
summary="List boards visible to the caller",
description=(
"Return boards the authenticated agent can access.\n\n"
"Use this as a discovery step before board-scoped operations."
),
openapi_extra={
"x-llm-intent": "agent_board_discovery",
"x-when-to-use": [
"Discover boards available to the current agent",
"Build a board selection list before read/write operations",
],
"x-when-not-to-use": [
"Use direct board-id endpoints when the target board is already known",
"Use task-only views when board context is not needed",
],
"x-required-actor": "any_agent",
"x-prerequisites": [
"Authenticated agent token",
"Read access policy enforcement applied",
],
"x-side-effects": [
"No persisted side effects",
],
"x-negative-guidance": [
"Do not use as a task mutation mechanism.",
"Do not treat this as a strict inventory cache endpoint.",
],
"x-routing-policy": [
"Use for board discovery before board-scoped actions.",
"Fallback to board-specific fetch or task routes once target is known.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent needs boards to plan next actions",
"required_privilege": "any_agent",
},
"decision": "agent_board_discovery",
},
{
"input": {
"intent": "board target is known",
"required_privilege": "any_agent",
},
"decision": "agent_get_board",
},
],
},
)
async def list_boards(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[BoardRead]:
"""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)
else:
# Main agents (board_id=None) must be scoped to their organization
# via their gateway to prevent cross-tenant board leakage.
gateway = await Gateway.objects.by_id(agent_ctx.agent.gateway_id).first(session)
if gateway is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Agent gateway not found; cannot determine organization scope.",
)
statement = statement.where(
col(Board.organization_id) == gateway.organization_id,
)
statement = statement.order_by(col(Board.created_at).desc())
return await paginate(session, statement)
@router.get(
"/boards/{board_id}",
response_model=BoardRead,
tags=AGENT_ALL_ROLE_TAGS,
summary="Fetch a board by id",
description=(
"Read a single board entity if it is visible to the authenticated agent.\n\n"
"Use for targeted planning and routing decisions."
),
openapi_extra={
"x-llm-intent": "agent_board_lookup",
"x-when-to-use": [
"Resolve board metadata before creating or updating board tasks",
"Validate board context before routing actions",
],
"x-when-not-to-use": [
"Bulk discovery of all accessible boards",
"Task list mutation workflows without board context",
],
"x-required-actor": "any_agent",
"x-prerequisites": [
"Authenticated agent token",
"Target board id must be accessible",
],
"x-side-effects": [
"No persisted side effects",
],
"x-negative-guidance": [
"Do not call for creating or mutating board fields.",
"Do not use when board_id is unknown; discover first.",
],
"x-routing-policy": [
"Use when a specific board id is known and validation of scope is needed.",
"Use task list endpoints for repeated board-scoped task discovery.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent needs full board context for planning",
"required_privilege": "any_agent",
},
"decision": "agent_board_lookup",
},
{
"input": {
"intent": "need multiple accessible boards first",
"required_privilege": "any_agent",
},
"decision": "agent_board_discovery",
},
],
},
)
def get_board(
board: Board = BOARD_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> Board:
"""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],
tags=AGENT_ALL_ROLE_TAGS,
summary="List visible agents",
description=(
"Return agents visible to the caller, optionally filtered by board.\n\n"
"Use when downstream routing or coordination needs recipient actors."
),
openapi_extra={
"x-llm-intent": "agent_roster_discovery",
"x-when-to-use": [
"Discover agents available for assignment or coordination",
"Build actor lists for lead and worker handoffs",
],
"x-when-not-to-use": [
"Fetching one specific agent identity (use agent lookup route if available)",
"Mutating agent state",
],
"x-required-actor": "any_agent",
"x-prerequisites": [
"Authenticated agent token",
"Optional board_id filter scoped by caller access",
],
"x-side-effects": [
"No persisted side effects",
],
"x-negative-guidance": [
"Do not use for agent lifecycle changes.",
"Do not assume full global visibility when filtered by board scopes.",
],
"x-routing-policy": [
"Use when coordination needs a roster and not a single agent lookup.",
"Use task or direct nudge endpoints for one-off actor targeting.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "find eligible agents on a board",
"required_privilege": "any_agent",
},
"decision": "agent_roster_discovery",
},
{
"input": {
"intent": "target one agent for coordination",
"required_privilege": "board_lead",
},
"decision": "agent_lead_nudge_agent",
},
],
},
)
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 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:
OpenClawAuthorizationPolicy.require_board_write_access(
allowed=board_id == agent_ctx.agent.board_id,
)
statement = statement.where(Agent.board_id == agent_ctx.agent.board_id)
elif board_id:
statement = statement.where(Agent.board_id == board_id)
statement = statement.order_by(col(Agent.created_at).desc())
def _transform(items: Sequence[Any]) -> Sequence[Any]:
agents = _coerce_agent_items(items)
return [
AgentLifecycleService.to_agent_read(
AgentLifecycleService.with_computed_status(agent),
)
for agent in agents
]
return await paginate(session, statement, transformer=_transform)
@router.get(
"/boards/{board_id}/tasks",
response_model=DefaultLimitOffsetPage[TaskRead],
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_task_discovery",
when_to_use=[
"Agent needs board task list for work selection or queue management.",
"Lead needs a filtered view for delegation planning.",
],
routing_examples=[
{
"input": {
"intent": "get assigned tasks for current agent",
"required_privilege": "any_agent",
},
"decision": "agent_board_task_discovery",
},
{
"input": {
"intent": "find unassigned backlog for delegation",
"required_privilege": "board_lead",
},
"decision": "agent_board_task_discovery",
},
],
),
)
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 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,
assigned_agent_id=filters.assigned_agent_id,
unassigned=filters.unassigned,
board=board,
session=session,
_actor=_actor(agent_ctx),
)
@router.get(
"/boards/{board_id}/tags",
response_model=list[TagRef],
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_tag_discovery",
when_to_use=[
"Agent needs available tags before creating or updating task payloads.",
],
routing_examples=[
{
"input": {
"intent": "resolve tag id for assignment update",
"required_privilege": "any_agent",
},
"decision": "agent_board_tag_discovery",
}
],
),
)
async def list_tags(
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> list[TagRef]:
"""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(
select(Tag)
.where(col(Tag.organization_id) == board.organization_id)
.order_by(func.lower(col(Tag.name)).asc(), col(Tag.created_at).asc()),
)
).all()
return [
TagRef(
id=tag.id,
name=tag.name,
slug=tag.slug,
color=tag.color,
)
for tag in tags
]
@router.get(
"/boards/{board_id}/webhooks/{webhook_id}/payloads/{payload_id}",
response_model=BoardWebhookPayloadRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_webhook_payload_read",
when_to_use=[
"Agent needs to inspect a previously captured webhook payload for this board.",
"Agent is reconciling missed webhook events or deduping inbound processing.",
],
routing_examples=[
{
"input": {
"intent": "inspect stored webhook payload by id",
"required_privilege": "any_agent",
},
"decision": "agent_board_webhook_payload_read",
},
{
"input": {
"intent": "list tasks for planning",
"required_privilege": "any_agent",
},
"decision": "agent_board_task_discovery",
},
],
),
)
async def get_webhook_payload(
webhook_id: UUID,
payload_id: UUID,
max_chars: int | None = Query(default=None, ge=1, le=1_000_000),
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> BoardWebhookPayloadRead:
"""Fetch a stored webhook payload (agent-accessible, read-only).
This enables board-scoped agents to backfill dropped webhook events and enforce
idempotency by inspecting previously received payloads.
If `max_chars` is provided and the serialized payload exceeds the limit,
the response payload is returned as a truncated string preview.
"""
_guard_board_access(agent_ctx, board)
payload = (
await session.exec(
select(BoardWebhookPayload)
.where(col(BoardWebhookPayload.id) == payload_id)
.where(col(BoardWebhookPayload.board_id) == board.id)
.where(col(BoardWebhookPayload.webhook_id) == webhook_id),
)
).first()
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
response = BoardWebhookPayloadRead.model_validate(payload, from_attributes=True)
if max_chars is not None and response.payload is not None:
preview, was_truncated = _payload_preview_with_limit(response.payload, max_chars=max_chars)
if was_truncated:
response.payload = preview
return response
@router.post(
"/boards/{board_id}/tasks",
response_model=TaskRead,
tags=AGENT_LEAD_TAGS,
summary="Create and assign a new board task as a lead agent",
description=(
"Create a new task on a board and persist lead metadata.\n\n"
"Use when a lead needs to introduce new work, create dependencies, "
"or directly assign ownership.\n"
"Do not use for task updates or comments; those are separate endpoints."
),
operation_id="agent_lead_create_task",
responses={
200: {"description": "Task created and persisted"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
404: {"model": LLMErrorResponse, "description": "Assigned target agent does not exist"},
409: {
"model": LLMErrorResponse,
"description": "Dependency or assignment validation failed",
},
422: {"model": LLMErrorResponse, "description": "Payload validation failed"},
},
openapi_extra={
"x-llm-intent": "delegate_work",
"x-when-to-use": [
"Lead needs to create a new backlog item for the board",
"Lead must set dependencies before work execution starts",
"Lead wants to assign an owner and notify another agent",
],
"x-when-not-to-use": [
"Updating an existing task",
"Adding progress comment",
"Pushing non-governed automation updates",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated lead token",
"board_id must be visible to lead",
"Optional tag/dependency IDs must exist",
],
"x-side-effects": [
"Creates a new task row",
"Creates dependency links",
"Writes tag/custom field entries",
"Rejects creation if dependency/assignment invariants fail",
],
"x-negative-guidance": [
"Do not call when updating an existing task or comment.",
"Do not mix owner reassignment with unknown dependency IDs.",
],
"x-routing-policy": [
"Lead-only routing: use this when converting a new board item into a task.",
"Fallback routing: use task update endpoints when the task already exists.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "lead wants to create a new issue with a new assignee",
"required_privilege": "board_lead",
},
"decision": "agent_lead_create_task",
},
{
"input": {
"intent": "existing task needs edits after creation",
"required_privilege": "board_lead",
},
"decision": "agent_boards_task_update",
},
],
},
)
async def create_task(
payload: TaskCreate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> TaskRead:
"""Create a task as the board lead.
Lead-only endpoint. Supports dependency-aware creation via
`depends_on_task_ids`, optional `tag_ids`, and `custom_field_values`.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
data = payload.model_dump(
exclude={"depends_on_task_ids", "tag_ids", "custom_field_values"},
)
depends_on_task_ids = list(payload.depends_on_task_ids)
tag_ids = list(payload.tag_ids)
custom_field_values = dict(payload.custom_field_values)
task = Task.model_validate(data)
task.board_id = board.id
task.auto_created = True
task.auto_reason = f"lead_agent:{agent_ctx.agent.id}"
normalized_deps = await validate_dependency_update(
session,
board_id=board.id,
task_id=task.id,
depends_on_task_ids=depends_on_task_ids,
)
normalized_tag_ids = await validate_tag_ids(
session,
organization_id=board.organization_id,
tag_ids=tag_ids,
)
dep_status = await dependency_status_by_id(
session,
board_id=board.id,
dependency_ids=normalized_deps,
)
blocked_by = blocked_by_dependency_ids(
dependency_ids=normalized_deps,
status_by_id=dep_status,
)
if blocked_by and (task.assigned_agent_id is not None or task.status != "inbox"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Task is blocked by incomplete dependencies.",
"blocked_by_task_ids": [str(value) for value in blocked_by],
},
)
if task.assigned_agent_id:
agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads cannot assign tasks to themselves.",
)
if agent.board_id and agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
session.add(task)
# Ensure the task exists in the DB before inserting dependency rows.
await session.flush()
await tasks_api._set_task_custom_field_values_for_create(
session,
board_id=board.id,
task_id=task.id,
custom_field_values=custom_field_values,
)
for dep_id in normalized_deps:
session.add(
TaskDependency(
board_id=board.id,
task_id=task.id,
depends_on_task_id=dep_id,
),
)
await replace_tags(
session,
task_id=task.id,
tag_ids=normalized_tag_ids,
)
await session.commit()
await session.refresh(task)
record_activity(
session,
event_type="task.created",
task_id=task.id,
message=f"Task created by lead: {task.title}.",
agent_id=agent_ctx.agent.id,
board_id=task.board_id,
)
await session.commit()
if task.assigned_agent_id:
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
session,
)
if assigned_agent:
await tasks_api.notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return await tasks_api._task_read_response(
session,
task=task,
board_id=board.id,
)
@router.patch(
"/boards/{board_id}/tasks/{task_id}",
response_model=TaskRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_task_update",
when_to_use=[
"Task state, ownership, dependencies, or inline status changes are needed.",
"Board member needs to publish progress updates to an existing task.",
],
routing_examples=[
{
"input": {
"intent": "worker updates task status and notes",
"required_privilege": "any_agent",
},
"decision": "agent_task_update",
},
{
"input": {
"intent": "lead reassigns ownership for load balancing",
"required_privilege": "board_lead",
},
"decision": "agent_task_update",
},
],
),
)
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 authorization checks.
Supports status, assignment, dependencies, and optional inline comment.
"""
_guard_task_access(agent_ctx, task)
return await tasks_api.update_task(
payload=payload,
task=task,
session=session,
actor=_actor(agent_ctx),
)
@router.delete(
"/boards/{board_id}/tasks/{task_id}",
response_model=OkResponse,
tags=AGENT_BOARD_TAGS,
summary="Delete a task as board lead",
description=(
"Delete a board task and related records.\n\n"
"This action is restricted to board lead agents."
),
openapi_extra=_agent_board_openapi_hints(
intent="agent_task_delete",
when_to_use=[
"Board lead needs to permanently remove an obsolete, duplicate, or invalid task.",
],
when_not_to_use=[
"Use task updates when status changes or reassignment is sufficient.",
],
required_actor="board_lead",
side_effects=[
"Deletes task comments, dependencies, tags, custom field values, and linked records.",
],
routing_examples=[
{
"input": {
"intent": "lead removes a duplicate task",
"required_privilege": "board_lead",
},
"decision": "agent_task_delete",
}
],
),
)
async def delete_task(
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""Delete a task after board-lead authorization checks."""
_guard_task_access(agent_ctx, task)
_require_board_lead(agent_ctx)
if task.board_id is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
await tasks_api.delete_task_and_related_records(session, task=task)
return OkResponse()
@router.get(
"/boards/{board_id}/tasks/{task_id}/comments",
response_model=DefaultLimitOffsetPage[TaskCommentRead],
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_task_comment_discovery",
when_to_use=[
"Review prior discussion before posting or modifying task comments.",
],
routing_examples=[
{
"input": {
"intent": "read collaboration history before sending updates",
"required_privilege": "any_agent",
},
"decision": "agent_task_comment_discovery",
}
],
),
)
async def list_task_comments(
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[TaskCommentRead]:
"""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,
session=session,
)
@router.post(
"/boards/{board_id}/tasks/{task_id}/comments",
response_model=TaskCommentRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_task_comment_create",
when_to_use=[
"Worker or lead needs to log progress, blockers, or coordination notes.",
],
routing_examples=[
{
"input": {
"intent": "add progress update comment",
"required_privilege": "any_agent",
},
"decision": "agent_task_comment_create",
}
],
),
)
async def create_task_comment(
payload: TaskCommentCreate,
task: Task = TASK_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> ActivityEvent:
"""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,
task=task,
session=session,
actor=_actor(agent_ctx),
)
@router.get(
"/boards/{board_id}/memory",
response_model=DefaultLimitOffsetPage[BoardMemoryRead],
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_memory_discovery",
when_to_use=[
"Agent needs board memory context before planning or status updates.",
"Agent needs to inspect durable context for coordination continuity.",
],
routing_examples=[
{
"input": {
"intent": "load board context before work planning",
"required_privilege": "any_agent",
},
"decision": "agent_board_memory_discovery",
}
],
),
)
async def list_board_memory(
is_chat: bool | None = IS_CHAT_QUERY,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[BoardMemoryRead]:
"""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,
board=board,
session=session,
_actor=_actor(agent_ctx),
)
@router.post(
"/boards/{board_id}/memory",
response_model=BoardMemoryRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_memory_record",
when_to_use=[
"Persist board-level context, decision, or handoff notes.",
"Archive chat-like coordination context for cross-agent continuity.",
],
routing_examples=[
{
"input": {
"intent": "record decision context for future turns",
"required_privilege": "any_agent",
},
"decision": "agent_board_memory_record",
}
],
side_effects=["Creates a board memory entry"],
routing_policy=["Use when new board context should be persisted."],
),
)
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.
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,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.get(
"/boards/{board_id}/approvals",
response_model=DefaultLimitOffsetPage[ApprovalRead],
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_approval_discovery",
when_to_use=[
"Agent needs to inspect outstanding approvals before acting on risky work.",
"Lead needs to monitor unresolved approvals on board operations.",
],
routing_examples=[
{
"input": {
"intent": "check pending approvals for a task",
"required_privilege": "any_agent",
},
"decision": "agent_board_approval_discovery",
}
],
),
)
async def list_approvals(
status_filter: ApprovalStatus | None = APPROVAL_STATUS_QUERY,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[ApprovalRead]:
"""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,
board=board,
session=session,
_actor=_actor(agent_ctx),
)
@router.post(
"/boards/{board_id}/approvals",
response_model=ApprovalRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_approval_request",
when_to_use=[
"Agent needs formal approval before unsafe or high-risk actions.",
],
routing_examples=[
{
"input": {
"intent": "request guardrail before risky execution",
"required_privilege": "any_agent",
},
"decision": "agent_board_approval_request",
}
],
required_actor="any_agent",
),
)
async def create_approval(
payload: ApprovalCreate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> ApprovalRead:
"""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,
board=board,
session=session,
_actor=_actor(agent_ctx),
)
@router.post(
"/boards/{board_id}/onboarding",
response_model=BoardOnboardingRead,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_onboarding_update",
when_to_use=[
"Initialize or refresh agent onboarding state for board workflows.",
],
routing_examples=[
{
"input": {
"intent": "record onboarding signal during workflow handoff",
"required_privilege": "any_agent",
},
"decision": "agent_board_onboarding_update",
}
],
),
)
async def update_onboarding(
payload: BoardOnboardingAgentUpdate,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> BoardOnboardingSession:
"""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,
board=board,
session=session,
actor=_actor(agent_ctx),
)
@router.post(
"/agents",
response_model=AgentRead,
tags=AGENT_LEAD_TAGS,
summary="Create a board agent as lead",
description=(
"Register a new board agent and attach it to the lead's board.\n\n"
"The target board is derived from the caller identity and cannot be "
"changed in payload."
),
operation_id="agent_lead_create_agent",
responses={
200: {"description": "Agent provisioned"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
409: {"model": LLMErrorResponse, "description": "Agent creation conflict"},
422: {"model": LLMErrorResponse, "description": "Payload validation failed"},
},
openapi_extra={
"x-llm-intent": "agent_management",
"x-when-to-use": [
"Need a new specialist for a board task flow",
"Scaling workforce with role-based agents",
],
"x-when-not-to-use": [
"Updating an existing agent",
"Creating non-board global actors",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated board lead",
"Valid AgentCreate payload",
],
"x-side-effects": [
"Creates agent row",
"Initializes lifecycle metadata",
"May trigger downstream provisioning",
],
"x-negative-guidance": [
"Do not use for modifying existing agents.",
"Do not create non-board agents through this endpoint.",
],
"x-routing-policy": [
"Use for first-time board agent onboarding and specialist expansion.",
"Use agent update endpoint for profile changes on an existing actor.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "board lead needs a new specialist agent",
"required_privilege": "board_lead",
},
"decision": "agent_lead_create_agent",
},
{
"input": {
"intent": "agent needs profile patch only",
"required_privilege": "board_lead",
},
"decision": "agent update payload path",
},
],
},
)
async def create_agent(
payload: AgentCreate,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentRead:
"""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},
)
return await agents_api.create_agent(
payload=payload,
session=session,
actor=_actor(agent_ctx),
)
@router.post(
"/boards/{board_id}/agents/{agent_id}/nudge",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
summary="Nudge an agent on a board",
description=(
"Send a direct coordination message to a specific board agent.\n\n"
"Use this when a lead sees stalled, idle, or misaligned work."
),
operation_id="agent_lead_nudge_agent",
responses={
200: {"description": "Nudge dispatched"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
404: {
"model": LLMErrorResponse,
"description": "Target agent does not exist",
},
422: {
"model": LLMErrorResponse,
"description": "Target agent cannot be reached",
},
502: {
"model": LLMErrorResponse,
"description": "Gateway dispatch failed",
},
},
openapi_extra={
"x-llm-intent": "agent_coordination",
"x-when-to-use": [
"Need to re-engage a worker quickly",
"Clarify expected output with a targeted nudge",
],
"x-when-not-to-use": [
"Mass notification to all agents",
"Escalation requiring human confirmation",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated board lead",
"Target agent on same board",
"nudge message content present",
],
"x-side-effects": [
"Emits coordination event",
"Persists nudge correlation for audit",
],
"x-negative-guidance": [
"Do not use for broadcast messages.",
"Do not use when no explicit target and no follow-up is required.",
],
"x-routing-policy": [
"Use for individual stalled or idle agent re-engagement.",
"Use broadcast route when multiple leads need synchronized coordination.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "one worker is idle on an assigned task",
"required_privilege": "board_lead",
},
"decision": "agent_lead_nudge_agent",
},
{
"input": {
"intent": "many leads need same instruction",
"required_privilege": "main_agent",
},
"decision": "agent_main_broadcast_lead_message",
},
],
},
)
async def nudge_agent(
payload: AgentNudge,
agent_id: str,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""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)
await coordination.nudge_board_agent(
board=board,
actor_agent=agent_ctx.agent,
target_agent_id=agent_id,
message=payload.message,
correlation_id=f"nudge:{board.id}:{agent_id}",
)
return OkResponse()
@router.post(
"/heartbeat",
response_model=AgentRead,
tags=AGENT_ALL_ROLE_TAGS,
summary="Upsert agent heartbeat",
description=(
"Record liveness for the authenticated agent.\n\n"
"Use this when the agent heartbeat loop checks in."
),
openapi_extra={
"x-llm-intent": "agent_heartbeat",
"x-when-to-use": [
"Agents should periodically update heartbeat to reflect liveness",
"Report transient status transitions for monitoring and routing",
],
"x-when-not-to-use": [
"Do not use for user-facing notifications.",
"Do not call with another agent identifier (agent is inferred).",
],
"x-required-actor": "any_agent",
"x-prerequisites": [
"Authenticated agent token",
"No request payload required",
],
"x-side-effects": [
"Updates agent heartbeat and status metadata",
"May emit activity for monitoring consumers",
],
"x-negative-guidance": [
"Do not send heartbeat updates at excessive frequencies.",
"Do not use heartbeat as task assignment signal.",
],
"x-routing-policy": [
"Use for periodic lifecycle status telemetry.",
"Do not use when the same actor needs a task-specific action.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent is returning from busy/idle status change",
"required_privilege": "any_agent",
},
"decision": "agent_heartbeat",
},
{
"input": {
"intent": "agent needs to escalate stalled task",
"required_privilege": "board_lead",
},
"decision": "agent_lead_nudge_agent",
},
],
},
)
async def agent_heartbeat(
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentRead:
"""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),
payload=AgentHeartbeat(),
session=session,
actor=_actor(agent_ctx),
)
@router.get(
"/boards/{board_id}/agents/{agent_id}/soul",
response_model=str,
tags=AGENT_BOARD_TAGS,
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_soul_lookup",
when_to_use=[
"Need an agent's SOUL guidance before deciding task instructions.",
"Lead or same-agent needs current role instructions for coordination.",
],
routing_examples=[
{
"input": {
"intent": "read actor behavior guidance",
"required_privilege": "board_lead_or_same_actor",
},
"decision": "agent_board_soul_lookup",
}
],
side_effects=["No persisted side effects"],
routing_policy=[
"Use for read-only retrieval of agent instruction sources.",
"Use task-specific channels for temporary guidance instead of stored SOUL.",
],
),
)
async def get_agent_soul(
agent_id: str,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> str:
"""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,
target_agent_id=agent_id,
)
coordination = GatewayCoordinationService(session)
try:
return await coordination.get_agent_soul(
board=board,
target_agent_id=agent_id,
correlation_id=f"soul.read:{board.id}:{agent_id}",
)
except HTTPException as exc:
# Keep explicit auth/not-found responses, but avoid relaying internal 5xx details.
if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR:
raise HTTPException(
status_code=exc.status_code,
detail="Gateway SOUL read failed",
) from exc
raise
except Exception as exc: # pragma: no cover - defensive API boundary guard
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Gateway SOUL read failed",
) from exc
@router.put(
"/boards/{board_id}/agents/{agent_id}/soul",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
summary="Update an agent's SOUL template",
description=(
"Write SOUL.md content for a board agent and persist it for reprovisioning.\n\n"
"Use this when role instructions or behavior guardrails need updates."
),
operation_id="agent_lead_update_agent_soul",
responses={
200: {"description": "SOUL updated"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
404: {
"model": LLMErrorResponse,
"description": "Board or target agent not found",
},
422: {
"model": LLMErrorResponse,
"description": "SOUL content is invalid or empty",
},
502: {
"model": LLMErrorResponse,
"description": "Gateway sync failed",
},
},
openapi_extra={
"x-llm-intent": "agent_knowledge_authoring",
"x-when-to-use": [
"Updating role behavior and recurring instructions",
"Changing runbook or policy defaults for an agent",
],
"x-when-not-to-use": [
"Posting transient task-specific guidance",
"Requesting human answer (use gateway ask-user)",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated board lead",
"Non-empty SOUL content",
"Target agent scoped to board",
],
"x-side-effects": [
"Updates soul_template in persistence",
"Syncs gateway-visible SOUL content",
"Creates coordination trace",
],
"x-negative-guidance": [
"Do not use for short, one-off task guidance.",
"Do not use for transient playbook snippets; use task comments instead.",
],
"x-routing-policy": [
"Use when updating recurring role behavior or runbook defaults.",
"Use task or gateway messages when scope is transient.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "lead wants to permanently change agent guardrails",
"required_privilege": "board_lead",
},
"decision": "agent_lead_update_agent_soul",
},
{
"input": {
"intent": "temporary note for current task",
"required_privilege": "board_lead",
},
"decision": "task comment creation endpoint",
},
],
},
)
async def update_agent_soul(
agent_id: str,
payload: SoulUpdateRequest,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> OkResponse:
"""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)
await coordination.update_agent_soul(
board=board,
target_agent_id=agent_id,
content=payload.content,
reason=payload.reason,
source_url=payload.source_url,
actor_agent_id=agent_ctx.agent.id,
correlation_id=f"soul.write:{board.id}:{agent_id}",
)
return OkResponse()
@router.delete(
"/boards/{board_id}/agents/{agent_id}",
response_model=OkResponse,
tags=AGENT_LEAD_TAGS,
summary="Delete a board agent as lead",
description=(
"Permanently remove a board agent and tear down associated lifecycle state.\n\n"
"Use sparingly; prefer reassignment for continuity-sensitive teams."
),
operation_id="agent_lead_delete_board_agent",
responses={
200: {"description": "Agent deleted"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
404: {
"model": LLMErrorResponse,
"description": "Board agent not found",
},
},
openapi_extra={
"x-llm-intent": "agent_lifecycle",
"x-when-to-use": [
"Removing duplicates or decommissioning temporary agents",
"Cleaning up after phase completion",
],
"x-when-not-to-use": [
"Temporary pausing (use status controls)",
"Migrating data ownership without actor removal",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated board lead",
"Agent scoped to same board",
],
"x-side-effects": [
"Deletes agent row and lifecycle state",
"Potentially revokes in-flight actions for deleted actor",
],
"x-negative-guidance": [
"Do not delete when temporary suspension is sufficient.",
"Do not use as an ownership transfer mechanism.",
],
"x-routing-policy": [
"Use only for permanent removal or decommission completion.",
"Use status updates for pause/enable workflows.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent role is no longer needed and should be removed",
"required_privilege": "board_lead",
},
"decision": "agent_lead_delete_board_agent",
},
{
"input": {
"intent": "agent needs temporary stop",
"required_privilege": "board_lead",
},
"decision": "agent status/assignment update",
},
],
},
)
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 board lead.
Cleans up runtime/session state through lifecycle services.
"""
_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,
tags=AGENT_LEAD_TAGS,
summary="Ask the human via gateway-main",
description=(
"Escalate a high-impact decision or ambiguity through the "
"gateway-main interaction channel.\n\n"
"Use when lead-level context needs human confirmation or consent."
),
operation_id="agent_lead_ask_user_via_gateway_main",
responses={
200: {"description": "Escalation accepted"},
403: {
"model": LLMErrorResponse,
"description": "Caller is not board lead",
},
404: {
"model": LLMErrorResponse,
"description": "Board context missing",
},
502: {
"model": LLMErrorResponse,
"description": "Gateway main handoff failed",
},
},
openapi_extra={
"x-llm-intent": "human_escalation",
"x-when-to-use": [
"Need explicit user confirmation",
"Blocking ambiguity requires human preference input",
],
"x-when-not-to-use": [
"Routine status notes",
"Low-signal alerts without action required",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Authenticated board lead",
"Configured gateway-main routing",
],
"x-side-effects": [
"Sends user-facing ask",
"Records escalation metadata",
],
"x-negative-guidance": [
"Do not use this for operational routing to another board lead.",
"Do not use when there is no blocking ambiguity or consent requirement.",
],
"x-routing-policy": [
"Use when user permission or preference is required.",
"Use lead-message route when you need an agent-to-lead control handoff.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "human consent required for permission-sensitive change",
"required_privilege": "board_lead",
},
"decision": "agent_lead_ask_user_via_gateway_main",
},
{
"input": {
"intent": "lead needs coordination from main, no user permission required",
"required_privilege": "agent_main",
},
"decision": "agent_main_message_board_lead",
},
],
},
)
async def ask_user_via_gateway_main(
payload: GatewayMainAskUserRequest,
board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayMainAskUserResponse:
"""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)
return await coordination.ask_user_via_gateway_main(
board=board,
payload=payload,
actor_agent=agent_ctx.agent,
)
@router.post(
"/gateway/boards/{board_id}/lead/message",
response_model=GatewayLeadMessageResponse,
tags=AGENT_MAIN_TAGS,
summary="Message board lead via gateway-main",
description=(
"Route a direct lead handoff or question from an agent to the board lead.\n\n"
"Use when a lead requires explicit, board-scoped routing."
),
operation_id="agent_main_message_board_lead",
responses={
200: {"description": "Lead message sent"},
403: {
"model": LLMErrorResponse,
"description": "Caller cannot message board lead",
},
404: {
"model": LLMErrorResponse,
"description": "Board or gateway binding not found",
},
422: {
"model": LLMErrorResponse,
"description": "Gateway configuration missing or invalid",
},
502: {
"model": LLMErrorResponse,
"description": "Gateway dispatch failed",
},
},
openapi_extra={
"x-llm-intent": "lead_direct_routing",
"x-when-to-use": [
"Need a single lead response for a specific board",
"Need a routed handoff that is not user-facing",
],
"x-when-not-to-use": [
"Broadcast message to multiple board leads",
"Human consent loops (use ask-user route)",
],
"x-required-actor": "agent_main",
"x-prerequisites": [
"Board lead destination available",
"Valid GatewayLeadMessageRequest payload",
],
"x-side-effects": [
"Creates direct lead routing dispatch",
"Records correlation and status",
],
"x-negative-guidance": [
"Do not use when your request must fan out to many leads.",
"Do not use for human permission questions.",
],
"x-routing-policy": [
"Use for single-board lead communication with direct follow-up.",
"Use broadcast route only when multi-board or multi-lead fan-out is needed.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "agent needs one lead review for board-specific blocker",
"required_privilege": "agent_main",
},
"decision": "agent_main_message_board_lead",
},
{
"input": {
"intent": "same notice needed across many leads",
"required_privilege": "agent_main",
},
"decision": "agent_main_broadcast_lead_message",
},
],
},
)
async def message_gateway_board_lead(
board_id: UUID,
payload: GatewayLeadMessageRequest,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadMessageResponse:
"""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,
board_id=board_id,
payload=payload,
)
@router.post(
"/gateway/leads/broadcast",
response_model=GatewayLeadBroadcastResponse,
tags=AGENT_MAIN_TAGS,
summary="Broadcast a message to board leads via gateway-main",
description=(
"Send a shared coordination request to multiple board leads.\n\n"
"Use for urgent cross-board or multi-lead fan-out patterns."
),
operation_id="agent_main_broadcast_lead_message",
openapi_extra={
"x-llm-intent": "lead_broadcast_routing",
"x-when-to-use": [
"Need to notify many leads with same context",
"Need aligned action across multiple board leads",
],
"x-when-not-to-use": [
"Single lead interaction is required",
"Human-facing consent request",
],
"x-required-actor": "agent_main",
"x-prerequisites": [
"Gateway-main routing identity available",
"GatewayLeadBroadcastRequest payload",
],
"x-side-effects": [
"Creates multi-recipient dispatch",
"Returns per-board status result entries",
],
"x-negative-guidance": [
"Do not use for sensitive single-lead tactical prompts.",
"Do not use for consent flows requiring explicit end-user input.",
],
"x-routing-policy": [
"Use when intent spans multiple board leads or operational domains.",
"Use single-lead message route for board-specific point-to-point communication.",
],
"x-routing-policy-examples": [
{
"input": {
"intent": "urgent incident notice required for multiple leads",
"required_privilege": "agent_main",
},
"decision": "agent_main_broadcast_lead_message",
},
{
"input": {
"intent": "single lead requires clarification before continuing",
"required_privilege": "agent_main",
},
"decision": "agent_main_message_board_lead",
},
],
},
responses={
200: {"description": "Broadcast completed"},
403: {
"model": LLMErrorResponse,
"description": "Caller cannot broadcast via gateway-main",
},
404: {
"model": LLMErrorResponse,
"description": "Gateway binding not found",
},
422: {
"model": LLMErrorResponse,
"description": "Gateway configuration missing or invalid",
},
502: {
"model": LLMErrorResponse,
"description": "Gateway dispatch partially failed",
},
},
)
async def broadcast_gateway_lead_message(
payload: GatewayLeadBroadcastRequest,
session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadBroadcastResponse:
"""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,
payload=payload,
)