diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 9a6d5c7..7cc356d 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -36,6 +36,7 @@ from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead 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, @@ -129,6 +130,44 @@ 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 _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) @@ -152,6 +191,54 @@ def _guard_task_access(agent_ctx: AgentAuthContext, task: Task) -> None: "/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, @@ -169,7 +256,59 @@ async def list_boards( return await paginate(session, statement) -@router.get("/boards/{board_id}", response_model=BoardRead, tags=AGENT_ALL_ROLE_TAGS) +@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, @@ -187,6 +326,54 @@ def get_board( "/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, @@ -224,6 +411,29 @@ async def list_agents( "/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, @@ -248,7 +458,26 @@ async def list_tasks( ) -@router.get("/boards/{board_id}/tags", response_model=list[TagRef], tags=AGENT_BOARD_TAGS) +@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, @@ -277,7 +506,78 @@ async def list_tags( ] -@router.post("/boards/{board_id}/tasks", response_model=TaskRead, tags=AGENT_LEAD_TAGS) +@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, @@ -397,6 +697,29 @@ async def create_task( "/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, @@ -421,6 +744,21 @@ async def update_task( "/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, @@ -442,6 +780,21 @@ async def list_task_comments( "/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, @@ -466,6 +819,22 @@ async def create_task_comment( "/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, @@ -486,7 +855,29 @@ async def list_board_memory( ) -@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead, tags=AGENT_BOARD_TAGS) +@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, @@ -510,6 +901,22 @@ async def create_board_memory( "/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, @@ -530,7 +937,27 @@ async def list_approvals( ) -@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead, tags=AGENT_BOARD_TAGS) +@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, @@ -554,6 +981,21 @@ async def create_approval( "/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, @@ -574,7 +1016,72 @@ async def update_onboarding( ) -@router.post("/agents", response_model=AgentRead, tags=AGENT_LEAD_TAGS) +@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, @@ -599,6 +1106,76 @@ async def create_agent( "/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, @@ -624,7 +1201,60 @@ async def nudge_agent( return OkResponse() -@router.post("/heartbeat", response_model=AgentRead, tags=AGENT_ALL_ROLE_TAGS) +@router.post( + "/heartbeat", + response_model=AgentRead, + tags=AGENT_ALL_ROLE_TAGS, + summary="Upsert agent heartbeat", + description=( + "Record liveness for the authenticated agent's current status.\n\n" + "Use this when the agent heartbeat loop reports status changes." + ), + 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", + "Valid AgentHeartbeatCreate payload", + ], + "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( payload: AgentHeartbeatCreate, session: AsyncSession = SESSION_DEP, @@ -647,6 +1277,27 @@ async def agent_heartbeat( "/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, @@ -675,6 +1326,77 @@ async def get_agent_soul( "/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, @@ -706,6 +1428,67 @@ async def update_agent_soul( "/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, @@ -730,6 +1513,72 @@ async def delete_board_agent( "/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, @@ -755,6 +1604,75 @@ async def ask_user_via_gateway_main( "/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, @@ -775,6 +1693,75 @@ async def message_gateway_board_lead( "/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, diff --git a/backend/app/api/board_group_memory.py b/backend/app/api/board_group_memory.py index 82abf44..3dfb3bc 100644 --- a/backend/app/api/board_group_memory.py +++ b/backend/app/api/board_group_memory.py @@ -72,6 +72,47 @@ _RUNTIME_TYPE_REFERENCES = (UUID,) AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"]) +def _agent_group_memory_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 when targeting a single actor or broadcast scope.", + ], + "x-required-actor": required_actor, + "x-prerequisites": prerequisites + or [ + "Authenticated actor token", + "Accessible board context", + ], + "x-side-effects": side_effects + or ["Persisted memory visibility changes may be observable across linked boards."], + "x-negative-guidance": negative_guidance + or [ + "Do not use as a replacement for direct task-specific commentary.", + "Do not assume infinite retention when group storage policies apply.", + ], + "x-routing-policy": routing_policy + or [ + "Use when board context requires shared memory discovery or posting.", + "Prefer narrow board endpoints for one-off lead/agent coordination needs.", + ], + "x-routing-policy-examples": routing_examples, + } + + def _parse_since(value: str | None) -> datetime | None: if not value: return None @@ -408,6 +449,27 @@ async def create_board_group_memory( "", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead], tags=AGENT_BOARD_ROLE_TAGS, + openapi_extra=_agent_group_memory_openapi_hints( + intent="agent_board_group_memory_discovery", + when_to_use=[ + "Inspect shared group memory for cross-board context before making decisions.", + "Collect active chat snapshots for a linked group before coordination actions.", + ], + routing_examples=[ + { + "input": { + "intent": "recover recent team memory for task framing", + "required_privilege": "agent_lead_or_worker", + }, + "decision": "agent_board_group_memory_discovery", + } + ], + side_effects=["No persisted side effects."], + routing_policy=[ + "Use as a shared-context discovery step before decisioning.", + "Use board-specific memory endpoints for direct board persistence updates.", + ], + ), ) async def list_board_group_memory_for_board( *, @@ -435,7 +497,31 @@ async def list_board_group_memory_for_board( return await paginate(session, queryset.statement) -@board_router.get("/stream", tags=AGENT_BOARD_ROLE_TAGS) +@board_router.get( + "/stream", + tags=AGENT_BOARD_ROLE_TAGS, + openapi_extra=_agent_group_memory_openapi_hints( + intent="agent_board_group_memory_stream", + when_to_use=[ + "Track shared group memory updates in near-real-time for live coordination.", + "React to newly added group messages without polling.", + ], + routing_examples=[ + { + "input": { + "intent": "subscribe to group memory updates for routing", + "required_privilege": "agent_lead_or_worker", + }, + "decision": "agent_board_group_memory_stream", + } + ], + side_effects=["No persisted side effects, streaming updates are read-only."], + routing_policy=[ + "Use when coordinated decisions need continuous group context.", + "Prefer bounded history reads when a snapshot is sufficient.", + ], + ), +) async def stream_board_group_memory_for_board( request: Request, *, @@ -472,7 +558,32 @@ async def stream_board_group_memory_for_board( return EventSourceResponse(event_generator(), ping=15) -@board_router.post("", response_model=BoardGroupMemoryRead, tags=AGENT_BOARD_ROLE_TAGS) +@board_router.post( + "", + response_model=BoardGroupMemoryRead, + tags=AGENT_BOARD_ROLE_TAGS, + openapi_extra=_agent_group_memory_openapi_hints( + intent="agent_board_group_memory_record", + when_to_use=[ + "Persist shared group memory for a linked group from board context.", + "Broadcast updates/messages to group-linked agents when chat or mention intent is present.", + ], + routing_examples=[ + { + "input": { + "intent": "share coordination signal in group memory", + "required_privilege": "board_agent", + }, + "decision": "agent_board_group_memory_record", + } + ], + side_effects=["Persist new group-memory entries with optional agent notification dispatch."], + routing_policy=[ + "Use for shared memory writes that should be visible across linked boards.", + "Prefer direct board memory endpoints for board-local persistence.", + ], + ), +) async def create_board_group_memory_for_board( payload: BoardGroupMemoryCreate, board: Board = BOARD_WRITE_DEP, diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index eee2eb0..db9ea37 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Any from uuid import UUID -from pydantic import field_validator +from pydantic import ConfigDict, Field, field_validator from sqlmodel import SQLModel from app.schemas.common import NonEmptyStr @@ -42,13 +42,64 @@ def _normalize_identity_profile( class AgentBase(SQLModel): """Common fields shared by agent create/read/update payloads.""" - board_id: UUID | None = None - name: NonEmptyStr - status: str = "provisioning" - heartbeat_config: dict[str, Any] | None = None - identity_profile: dict[str, Any] | None = None - identity_template: str | None = None - soul_template: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_profile", + "x-when-to-use": [ + "Create or update canonical agent metadata", + "Inspect agent attributes for governance or delegation", + ], + "x-when-not-to-use": [ + "Task lifecycle operations (use task endpoints)", + "User-facing conversation content (not modeled here)", + ], + "x-required-actor": "lead_or_worker_agent", + "x-prerequisites": [ + "board_id if required by your board policy", + "identity templates should be valid JSON or text with expected markers", + ], + "x-response-shape": "AgentRead", + "x-side-effects": [ + "Reads or writes core agent profile fields", + "May impact routing or assignment decisions when persisted", + ], + }, + ) + + board_id: UUID | None = Field( + default=None, + description="Board id that scopes this agent. Omit only when policy allows global agents.", + examples=["11111111-1111-1111-1111-111111111111"], + ) + name: NonEmptyStr = Field( + description="Human-readable agent display name.", + examples=["Ops triage lead"], + ) + status: str = Field( + default="provisioning", + description="Current lifecycle state used by coordinator logic.", + examples=["provisioning", "active", "paused", "retired"], + ) + heartbeat_config: dict[str, Any] | None = Field( + default=None, + description="Runtime heartbeat behavior overrides for this agent.", + examples=[{"interval_seconds": 30, "missing_tolerance": 120}], + ) + identity_profile: dict[str, Any] | None = Field( + default=None, + description="Optional profile hints used by routing and policy checks.", + examples=[{"role": "incident_lead", "skill": "triage"}], + ) + identity_template: str | None = Field( + default=None, + description="Template that helps define initial intent and behavior.", + examples=["You are a senior incident response lead."], + ) + soul_template: str | None = Field( + default=None, + description="Template representing deeper agent instructions.", + examples=["When critical blockers appear, escalate in plain language."], + ) @field_validator("identity_template", "soul_template", mode="before") @classmethod @@ -78,14 +129,66 @@ class AgentCreate(AgentBase): class AgentUpdate(SQLModel): """Payload for patching an existing agent.""" - board_id: UUID | None = None - is_gateway_main: bool | None = None - name: NonEmptyStr | None = None - status: str | None = None - heartbeat_config: dict[str, Any] | None = None - identity_profile: dict[str, Any] | None = None - identity_template: str | None = None - soul_template: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_profile_update", + "x-when-to-use": [ + "Patch mutable agent metadata without replacing the full payload", + "Update status, templates, or heartbeat policy", + ], + "x-when-not-to-use": [ + "Creating an agent (use AgentCreate)", + "Hard deletes or archive actions (use lifecycle endpoints)", + ], + "x-required-actor": "board_lead", + "x-prerequisites": [ + "Target agent id must exist and be visible to actor context", + ], + "x-side-effects": [ + "Mutates agent profile state", + ], + }, + ) + + board_id: UUID | None = Field( + default=None, + description="Optional new board assignment.", + examples=["22222222-2222-2222-2222-222222222222"], + ) + is_gateway_main: bool | None = Field( + default=None, + description="Whether this agent is treated as the board gateway main.", + ) + name: NonEmptyStr | None = Field( + default=None, + description="Optional replacement display name.", + examples=["Ops triage lead"], + ) + status: str | None = Field( + default=None, + description="Optional replacement lifecycle status.", + examples=["active", "paused"], + ) + heartbeat_config: dict[str, Any] | None = Field( + default=None, + description="Optional heartbeat policy override.", + examples=[{"interval_seconds": 45}], + ) + identity_profile: dict[str, Any] | None = Field( + default=None, + description="Optional identity profile update values.", + examples=[{"role": "coordinator"}], + ) + identity_template: str | None = Field( + default=None, + description="Optional replacement identity template.", + examples=["Focus on root cause analysis first."], + ) + soul_template: str | None = Field( + default=None, + description="Optional replacement soul template.", + examples=["Escalate only after checking all known mitigations."], + ) @field_validator("identity_template", "soul_template", mode="before") @classmethod @@ -111,30 +214,102 @@ class AgentUpdate(SQLModel): class AgentRead(AgentBase): """Public agent representation returned by the API.""" - id: UUID - gateway_id: UUID - is_board_lead: bool = False - is_gateway_main: bool = False - openclaw_session_id: str | None = None - last_seen_at: datetime | None - created_at: datetime - updated_at: datetime + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_profile_lookup", + "x-when-to-use": [ + "Inspect live agent state for routing and ownership decisions", + ], + "x-required-actor": "board_lead_or_worker", + "x-interpretation": "This is a read model; changes here should use update/lifecycle endpoints.", + }, + ) + + id: UUID = Field(description="Agent UUID.") + gateway_id: UUID = Field(description="Gateway UUID that manages this agent.") + is_board_lead: bool = Field( + default=False, + description="Whether this agent is the board lead.", + ) + is_gateway_main: bool = Field( + default=False, + description="Whether this agent is the primary gateway agent.", + ) + openclaw_session_id: str | None = Field( + default=None, + description="Optional openclaw session token.", + examples=["sess_01J..."], + ) + last_seen_at: datetime | None = Field( + default=None, + description="Last heartbeat timestamp.", + ) + created_at: datetime = Field(description="Creation timestamp.") + updated_at: datetime = Field(description="Last update timestamp.") class AgentHeartbeat(SQLModel): """Heartbeat status payload sent by agents.""" - status: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_health_signal", + "x-when-to-use": [ + "Send periodic heartbeat to indicate liveness", + ], + "x-required-actor": "any_agent", + "x-response-shape": "AgentRead", + }, + ) + + status: str | None = Field( + default=None, + description="Agent health status string.", + examples=["healthy", "offline", "degraded"], + ) class AgentHeartbeatCreate(AgentHeartbeat): """Heartbeat payload used to create an agent lazily.""" - name: NonEmptyStr - board_id: UUID | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_bootstrap", + "x-when-to-use": [ + "First heartbeat from a non-provisioned worker should bootstrap identity.", + ], + "x-required-actor": "agent", + "x-prerequisites": ["Agent auth token already validated"], + "x-response-shape": "AgentRead", + }, + ) + + name: NonEmptyStr = Field( + description="Display name assigned during first heartbeat bootstrap.", + examples=["Ops triage lead"], + ) + board_id: UUID | None = Field( + default=None, + description="Optional board context for bootstrap.", + examples=["33333333-3333-3333-3333-333333333333"], + ) class AgentNudge(SQLModel): """Nudge message payload for pinging an agent.""" - message: NonEmptyStr + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "agent_nudge", + "x-when-to-use": [ + "Prompt a specific agent to revisit or reprioritize work.", + ], + "x-required-actor": "board_lead", + "x-response-shape": "AgentRead", + }, + ) + + message: NonEmptyStr = Field( + description="Short message to direct an agent toward immediate attention.", + examples=["Please update the incident triage status for task T-001."], + ) diff --git a/backend/app/schemas/errors.py b/backend/app/schemas/errors.py index c4decc4..30a78e9 100644 --- a/backend/app/schemas/errors.py +++ b/backend/app/schemas/errors.py @@ -2,7 +2,49 @@ from __future__ import annotations -from sqlmodel import Field, SQLModel +from pydantic import ConfigDict, Field +from sqlmodel import SQLModel + + +class LLMErrorResponse(SQLModel): + """Standardized LLM-facing error payload used by API contracts.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "LLMErrorResponse", + "x-llm-intent": "llm_error_handling", + "x-when-to-use": [ + "Structured, tool-facing API errors for agent workflows", + "Gateway handoff and delegated-task operations", + ], + "x-required-actor": "agent", + "x-side-effects": [ + "Returns explicit machine-readable error context", + "Includes request_id for end-to-end traceability", + ], + }, + ) + + detail: str | dict[str, object] | list[object] = Field( + description=( + "Error payload. Agents should rely on `code` when present and default " + "to `message` for fallback display." + ), + examples=["Invalid payload for lead escalation.", {"code": "not_found", "message": "Agent not found."}], + ) + request_id: str | None = Field( + default=None, + description="Request correlation identifier injected by middleware.", + ) + code: str | None = Field( + default=None, + description="Optional machine-readable error code.", + examples=["gateway_unavailable", "dependency_validation_failed"], + ) + retryable: bool | None = Field( + default=None, + description="Whether a client should retry the call after remediating transient conditions.", + ) class BlockedTaskDetail(SQLModel): diff --git a/backend/app/schemas/gateway_coordination.py b/backend/app/schemas/gateway_coordination.py index 42edcf9..d7d3691 100644 --- a/backend/app/schemas/gateway_coordination.py +++ b/backend/app/schemas/gateway_coordination.py @@ -5,7 +5,8 @@ from __future__ import annotations from typing import Literal from uuid import UUID -from sqlmodel import Field, SQLModel +from pydantic import ConfigDict, Field +from sqlmodel import SQLModel from app.schemas.common import NonEmptyStr @@ -23,72 +24,255 @@ def _user_reply_tags() -> list[str]: class GatewayLeadMessageRequest(SQLModel): """Request payload for sending a message to a board lead agent.""" - kind: Literal["question", "handoff"] = "question" - correlation_id: str | None = None - content: NonEmptyStr + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "lead_direct_message", + "x-when-to-use": [ + "A board has an urgent tactical request that needs direct lead routing", + "You need a specific lead response before delegating work", + ], + "x-when-not-to-use": [ + "Broadcasting to many leads (use broadcast request)", + "Requesting end-user decisions (use ask-user request)", + ], + "x-required-actor": "main_agent", + "x-response-shape": "GatewayLeadMessageResponse", + }, + ) + + kind: Literal["question", "handoff"] = Field( + default="question", + description="Routing mode for lead messages.", + examples=["question", "handoff"], + ) + correlation_id: str | None = Field( + default=None, + description="Optional correlation token shared across upstream and downstream systems.", + examples=["lead-msg-1234"], + ) + content: NonEmptyStr = Field( + description="Human-readable body sent to lead agents.", + examples=["Please triage the highest-priority blocker on board X."], + ) # How the lead should reply (defaults are interpreted by templates). - reply_tags: list[str] = Field(default_factory=_lead_reply_tags) - reply_source: str | None = "lead_to_gateway_main" + reply_tags: list[str] = Field( + default_factory=_lead_reply_tags, + description="Tags required by reply templates when the lead responds.", + examples=[["gateway_main", "lead_reply"]], + ) + reply_source: str | None = Field( + default="lead_to_gateway_main", + description="Reply destination key for the orchestrator.", + examples=["lead_to_gateway_main"], + ) class GatewayLeadMessageResponse(SQLModel): """Response payload for a lead-message dispatch attempt.""" - ok: bool = True - board_id: UUID - lead_agent_id: UUID | None = None - lead_agent_name: str | None = None - lead_created: bool = False + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "lead_direct_message_result", + "x-when-to-use": [ + "Confirm lead routing outcome for a direct message request.", + ], + "x-when-not-to-use": [ + "Broadcast outcomes (use GatewayLeadBroadcastResponse)", + ], + "x-required-actor": "gateway_main", + "x-interpretation": "Use to confirm handoff path and recipient lead context.", + }, + ) + + ok: bool = Field(default=True, description="Whether dispatch was accepted.") + board_id: UUID = Field(description="Board receiving the message.") + lead_agent_id: UUID | None = Field( + default=None, + description="Resolved lead agent id when present.", + ) + lead_agent_name: str | None = Field( + default=None, + description="Resolved lead agent display name.", + ) + lead_created: bool = Field( + default=False, + description="Whether a lead fallback actor was created during routing.", + ) class GatewayLeadBroadcastRequest(SQLModel): """Request payload for broadcasting a message to multiple board leads.""" - kind: Literal["question", "handoff"] = "question" - correlation_id: str | None = None - content: NonEmptyStr - board_ids: list[UUID] | None = None - reply_tags: list[str] = Field(default_factory=_lead_reply_tags) - reply_source: str | None = "lead_to_gateway_main" + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "lead_broadcast_message", + "x-when-to-use": [ + "Multiple board leads need the same message", + "Coordinating cross-board operational alerts", + ], + "x-when-not-to-use": [ + "Single lead response required (use direct message)", + "Personalized board-level instruction from agent context", + ], + "x-required-actor": "main_agent", + "x-response-shape": "GatewayLeadBroadcastResponse", + }, + ) + + kind: Literal["question", "handoff"] = Field( + default="question", + description="Broadcast intent. `question` asks for responses; `handoff` requests transfer.", + examples=["question", "handoff"], + ) + correlation_id: str | None = Field( + default=None, + description="Optional correlation token shared with downstream handlers.", + examples=["broadcast-2026-02-14"], + ) + content: NonEmptyStr = Field( + description="Message content distributed to selected board leads.", + examples=["Board-wide incident: prioritize risk triage on task set 14."], + ) + board_ids: list[UUID] | None = Field( + default=None, + description="Optional explicit list of board IDs; omit for lead-scoped defaults.", + examples=[[ "11111111-1111-1111-1111-111111111111" ]], + ) + reply_tags: list[str] = Field( + default_factory=_lead_reply_tags, + description="Tags required by reply templates when each lead responds.", + examples=[["gateway_main", "lead_reply"]], + ) + reply_source: str | None = Field( + default="lead_to_gateway_main", + description="Reply destination key for broadcast responses.", + examples=["lead_to_gateway_main"], + ) class GatewayLeadBroadcastBoardResult(SQLModel): """Per-board result entry for a lead broadcast operation.""" - board_id: UUID - lead_agent_id: UUID | None = None - lead_agent_name: str | None = None - ok: bool = False - error: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "lead_broadcast_status", + "x-when-to-use": [ + "Reading per-board outcomes for retries/follow-up workflows", + ], + "x-when-not-to-use": ["Global summary checks should use parent broadcast response"], + "x-interpretation": "Use this result object as a transport status for one board.", + }, + ) + + board_id: UUID = Field(description="Target board id for this result.") + lead_agent_id: UUID | None = Field( + default=None, + description="Resolved lead agent id for the target board.", + ) + lead_agent_name: str | None = Field( + default=None, + description="Resolved lead agent display name.", + ) + ok: bool = Field(default=False, description="Whether this board delivery succeeded.") + error: str | None = Field( + default=None, + description="Failure reason if this board failed.", + ) class GatewayLeadBroadcastResponse(SQLModel): """Aggregate response for a lead broadcast operation.""" - ok: bool = True - sent: int = 0 - failed: int = 0 + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "lead_broadcast_summary", + "x-when-to-use": [ + "Inspect final counters after attempting a multi-board send.", + ], + "x-when-not-to-use": [ + "Single-board directed lead message (use GatewayLeadMessageResponse)", + ], + "x-required-actor": "lead_agent_or_router", + "x-interpretation": "Use sent/failed counters before considering retry logic.", + "x-response-shape": "List of GatewayLeadBroadcastBoardResult", + }, + ) + + ok: bool = Field(default=True, description="Whether broadcast execution succeeded.") + sent: int = Field(default=0, description="Number of boards successfully messaged.") + failed: int = Field(default=0, description="Number of boards that failed messaging.") results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list) class GatewayMainAskUserRequest(SQLModel): """Request payload for asking the end user via a main gateway agent.""" - correlation_id: str | None = None - content: NonEmptyStr - preferred_channel: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "human_escalation_request", + "x-when-to-use": [ + "Blocking decision requires explicit user input", + "Task flow requires preference confirmation or permission", + ], + "x-required-actor": "lead_agent", + "x-response-shape": "GatewayMainAskUserResponse", + }, + ) + + correlation_id: str | None = Field( + default=None, + description="Optional correlation token for tracing request/response flow.", + examples=["ask-user-001"], + ) + content: NonEmptyStr = Field( + description="Prompt that should be asked to the human.", + examples=["Can we proceed with the proposed vendor budget increase?"], + ) + preferred_channel: str | None = Field( + default=None, + description="Optional preferred messaging channel.", + examples=["chat", "email"], + ) # How the main agent should reply back into Mission Control # (defaults interpreted by templates). - reply_tags: list[str] = Field(default_factory=_user_reply_tags) - reply_source: str | None = "user_via_gateway_main" + reply_tags: list[str] = Field( + default_factory=_user_reply_tags, + description="Tags required for routing the user response.", + examples=[["gateway_main", "user_reply"]], + ) + reply_source: str | None = Field( + default="user_via_gateway_main", + description="Reply destination key for user confirmation loops.", + examples=["user_via_gateway_main"], + ) class GatewayMainAskUserResponse(SQLModel): """Response payload for user-question dispatch via gateway main agent.""" - ok: bool = True - board_id: UUID - main_agent_id: UUID | None = None - main_agent_name: str | None = None + model_config = ConfigDict( + json_schema_extra={ + "x-llm-intent": "human_escalation_result", + "x-when-to-use": [ + "Track completion and main-agent handoff after human escalation request.", + ], + "x-when-not-to-use": [ + "Regular lead routing outcomes (use lead message/broadcast responses)", + ], + "x-required-actor": "lead_agent", + "x-interpretation": "Track whether ask was accepted and which main agent handled it.", + }, + ) + + ok: bool = Field(default=True, description="Whether ask-user dispatch was accepted.") + board_id: UUID = Field(description="Board context used for the request.") + main_agent_id: UUID | None = Field( + default=None, + description="Resolved main agent id handling the ask.", + ) + main_agent_name: str | None = Field( + default=None, + description="Resolved main agent display name.", + ) diff --git a/backend/tests/test_openapi_agent_role_tags.py b/backend/tests/test_openapi_agent_role_tags.py index 8b12f5c..299f907 100644 --- a/backend/tests/test_openapi_agent_role_tags.py +++ b/backend/tests/test_openapi_agent_role_tags.py @@ -16,6 +16,10 @@ def _op_description(schema: dict[str, object], *, path: str, method: str) -> str return str(op.get("description", "")).strip() +def _schema_by_name(schema: dict[str, object], name: str) -> dict[str, object]: + return schema["components"]["schemas"][name] # type: ignore[return-value] + + def test_openapi_agent_role_tags_are_exposed() -> None: """Role tags should be queryable without path-based heuristics.""" schema = app.openapi() @@ -30,6 +34,21 @@ def test_openapi_agent_role_tags_are_exposed() -> None: path="/api/v1/agent/boards/{board_id}/tasks", method="get", ) + assert "agent-main" in _op_tags( + schema, + path="/api/v1/agent/boards", + method="get", + ) + assert "agent-main" in _op_tags( + schema, + path="/api/v1/agent/boards/{board_id}", + method="get", + ) + assert "agent-main" in _op_tags( + schema, + path="/api/v1/agent/agents", + method="get", + ) assert "agent-main" in _op_tags( schema, path="/api/v1/agent/gateway/leads/broadcast", @@ -78,3 +97,121 @@ def test_openapi_agent_role_endpoint_descriptions_exist() -> None: path="/api/v1/boards/{board_id}/group-snapshot", method="get", ) + + +def test_openapi_agent_tool_endpoints_include_llm_hints() -> None: + """Tool-facing agent endpoints should expose structured usage hints and operation IDs.""" + schema = app.openapi() + op_ids: set[str] = set() + + expected_paths = [ + ("/api/v1/agent/boards", "get"), + ("/api/v1/agent/boards/{board_id}", "get"), + ("/api/v1/agent/agents", "get"), + ("/api/v1/agent/heartbeat", "post"), + ("/api/v1/agent/boards/{board_id}/tasks", "post"), + ("/api/v1/agent/boards/{board_id}/tasks", "get"), + ("/api/v1/agent/boards/{board_id}/tags", "get"), + ("/api/v1/agent/boards/{board_id}/tasks/{task_id}", "patch"), + ("/api/v1/agent/boards/{board_id}/tasks/{task_id}/comments", "get"), + ("/api/v1/agent/boards/{board_id}/tasks/{task_id}/comments", "post"), + ("/api/v1/agent/boards/{board_id}/memory", "get"), + ("/api/v1/agent/boards/{board_id}/memory", "post"), + ("/api/v1/boards/{board_id}/group-memory", "get"), + ("/api/v1/boards/{board_id}/group-memory", "post"), + ("/api/v1/boards/{board_id}/group-memory/stream", "get"), + ("/api/v1/agent/boards/{board_id}/approvals", "get"), + ("/api/v1/agent/boards/{board_id}/approvals", "post"), + ("/api/v1/agent/boards/{board_id}/onboarding", "post"), + ("/api/v1/agent/boards/{board_id}/agents/{agent_id}/soul", "get"), + ("/api/v1/agent/agents", "post"), + ("/api/v1/agent/boards/{board_id}/agents/{agent_id}/nudge", "post"), + ("/api/v1/agent/boards/{board_id}/agents/{agent_id}/soul", "put"), + ("/api/v1/agent/boards/{board_id}/agents/{agent_id}", "delete"), + ("/api/v1/agent/boards/{board_id}/gateway/main/ask-user", "post"), + ("/api/v1/agent/gateway/boards/{board_id}/lead/message", "post"), + ("/api/v1/agent/gateway/leads/broadcast", "post"), + ] + for path, method in expected_paths: + op = schema["paths"][path][method] + assert "x-llm-intent" in op + assert isinstance(op["x-llm-intent"], str) + assert op["x-llm-intent"] + assert "x-negative-guidance" in op + assert isinstance(op["x-negative-guidance"], list) + assert op["x-negative-guidance"] + assert all(isinstance(item, str) and item for item in op["x-negative-guidance"]) + assert "x-when-to-use" in op + assert op["x-when-to-use"] + assert "x-routing-policy" in op + assert op["x-routing-policy"] + assert isinstance(op["x-routing-policy"], list) + assert op["x-routing-policy"] + assert all(isinstance(item, str) and item for item in op["x-routing-policy"]) + assert "x-required-actor" in op + assert "operationId" in op + assert isinstance(op["operationId"], str) + assert op["operationId"] + assert "x-routing-policy-examples" in op + assert isinstance(op["x-routing-policy-examples"], list) + assert op["x-routing-policy-examples"] + assert all( + isinstance(example, dict) + and "decision" in example + and "input" in example + and isinstance(example["decision"], str) + and example["decision"].strip() + and isinstance(example["input"], dict) + and "intent" in example["input"] + and isinstance(example["input"]["intent"], str) + and example["input"]["intent"].strip() + for example in op["x-routing-policy-examples"] + ) + op_ids.add(op["operationId"]) + responses = op.get("responses", {}) + assert responses + assert len(op_ids) == len(expected_paths) + + +def test_openapi_agent_schemas_include_discoverability_hints() -> None: + """Schema-level metadata should advertise usage context for model-driven tooling.""" + schema = app.openapi() + + expected_schema_hints = [ + ("AgentCreate", "agent_profile"), + ("AgentUpdate", "agent_profile_update"), + ("AgentRead", "agent_profile_lookup"), + ("GatewayLeadMessageRequest", "lead_direct_message"), + ("GatewayLeadMessageResponse", "lead_direct_message_result"), + ("GatewayLeadBroadcastResponse", "lead_broadcast_summary"), + ("GatewayMainAskUserRequest", "human_escalation_request"), + ("GatewayMainAskUserResponse", "human_escalation_result"), + ("AgentNudge", "agent_nudge"), + ] + for schema_name, intent in expected_schema_hints: + component = _schema_by_name(schema, schema_name) + assert "x-llm-intent" in component + assert component["x-llm-intent"] == intent + assert component.get("x-when-to-use") + assert component.get("x-required-actor") or component_name_is_query(schema_name) + + +def schema_name_is_query(schema_name: str) -> bool: + """Some pure response shapes are actor-agnostic and expose interpretation instead.""" + return schema_name in {"GatewayLeadBroadcastResponse", "GatewayMainAskUserResponse"} + + +def test_openapi_agent_schema_fields_have_context() -> None: + """Request/response fields should include field-level usage hints.""" + schema = app.openapi() + + request_schema = _schema_by_name(schema, "GatewayLeadMessageRequest") + props = request_schema["properties"] # type: ignore[assignment] + assert "kind" in props + assert props["kind"]["description"] + assert props["kind"]["description"].startswith("Routing mode") + + nudge_schema = _schema_by_name(schema, "AgentNudge") + nudge_props = nudge_schema["properties"] # type: ignore[assignment] + assert "message" in nudge_props + assert nudge_props["message"]["description"]