diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 155c0460..70a73589 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone import asyncio import json +import re from collections import deque from uuid import UUID @@ -46,6 +47,7 @@ TASK_EVENT_TYPES = { "task.comment", } SSE_SEEN_MAX = 2000 +MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})") def validate_task_status(status_value: str) -> None: @@ -101,6 +103,43 @@ def _parse_since(value: str | None) -> datetime | None: return parsed +def _extract_mentions(message: str) -> set[str]: + return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)} + + +def _matches_mention(agent: Agent, mentions: set[str]) -> bool: + if not mentions: + return False + name = (agent.name or "").strip() + if not name: + return False + normalized = name.lower() + if normalized in mentions: + return True + first = normalized.split()[0] + return first in mentions + + +def _lead_was_mentioned( + session: Session, + task: Task, + lead: Agent, +) -> bool: + statement = ( + select(ActivityEvent.message) + .where(col(ActivityEvent.task_id) == task.id) + .where(col(ActivityEvent.event_type) == "task.comment") + .order_by(desc(col(ActivityEvent.created_at))) + ) + for message in session.exec(statement): + if not message: + continue + mentions = _extract_mentions(message) + if _matches_mention(lead, mentions): + return True + return False + + def _fetch_task_events( board_id: UUID, since: datetime, @@ -653,10 +692,13 @@ def create_task_comment( ) -> ActivityEvent: if actor.actor_type == "agent" and actor.agent: if actor.agent.is_board_lead and task.status != "review": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads can only comment during review.", - ) + if not _lead_was_mentioned(session, task, actor.agent): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Board leads can only comment during review or when mentioned." + ), + ) if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if not payload.message.strip(): @@ -670,28 +712,50 @@ def create_task_comment( session.add(event) session.commit() session.refresh(event) + mention_names = _extract_mentions(payload.message) + targets: dict[UUID, Agent] = {} if task.assigned_agent_id: - if ( - actor.actor_type == "agent" - and actor.agent - and actor.agent.id == task.assigned_agent_id - ): - return event - agent = session.get(Agent, task.assigned_agent_id) - if agent and agent.openclaw_session_id: - board = session.get(Board, task.board_id) if task.board_id else None - config = _gateway_config(session, board) if board else None - if board and config: - snippet = payload.message.strip() - if len(snippet) > 500: - snippet = f"{snippet[:497]}..." + assigned_agent = session.get(Agent, task.assigned_agent_id) + if assigned_agent: + targets[assigned_agent.id] = assigned_agent + if mention_names and task.board_id: + statement = select(Agent).where(col(Agent.board_id) == task.board_id) + for agent in session.exec(statement): + if _matches_mention(agent, mention_names): + targets[agent.id] = agent + if actor.actor_type == "agent" and actor.agent: + targets.pop(actor.agent.id, None) + if targets: + board = session.get(Board, task.board_id) if task.board_id else None + config = _gateway_config(session, board) if board else None + if board and config: + snippet = payload.message.strip() + if len(snippet) > 500: + snippet = f"{snippet[:497]}..." + actor_name = ( + actor.agent.name + if actor.actor_type == "agent" and actor.agent + else "User" + ) + for agent in targets.values(): + if not agent.openclaw_session_id: + continue + mentioned = _matches_mention(agent, mention_names) + header = "TASK MENTION" if mentioned else "NEW TASK COMMENT" + action_line = ( + "You were mentioned in this comment." + if mentioned + else "A new comment was posted on your task." + ) message = ( - "NEW TASK COMMENT\n" + f"{header}\n" f"Board: {board.name}\n" f"Task: {task.title}\n" - f"Task ID: {task.id}\n\n" + f"Task ID: {task.id}\n" + f"From: {actor_name}\n\n" + f"{action_line}\n\n" f"Comment:\n{snippet}\n\n" - "Review and respond in the task thread." + "If you are mentioned but not assigned, reply in the task thread but do not change task status." ) try: asyncio.run( diff --git a/templates/HEARTBEAT_AGENT.md b/templates/HEARTBEAT_AGENT.md index 1cbcb67d..7b613c96 100644 --- a/templates/HEARTBEAT_AGENT.md +++ b/templates/HEARTBEAT_AGENT.md @@ -23,6 +23,11 @@ If any required input is missing, stop and request a provisioning update. - Every status change must have a comment within 30 seconds. - Do not claim a new task if you already have one in progress. +## Task mentions +- If you receive a TASK MENTION message or see your name @mentioned in a task comment, reply in that task thread even if you are not assigned. +- Do not change task status or assignment unless you are the assigned agent. +- Keep the reply focused on the mention request. + ## Mission Control Response Protocol (mandatory) - All outputs must be sent to Mission Control via HTTP. - Always include: `X-Agent-Token: {{ auth_token }}` diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index 26f8a16c..4ff36538 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -26,6 +26,10 @@ If any required input is missing, stop and request a provisioning update. - You are responsible for **increasing collaboration among other agents**. Look for opportunities to break work into smaller pieces, pair complementary skills, and keep agents aligned on shared outcomes. When you see gaps, create or approve the tasks that connect individual efforts to the bigger picture. - When you leave review feedback, format it as clean markdown. Use headings/bullets/tables when helpful, but only when it improves clarity. +## Task mentions +- If you are @mentioned in a task comment, you may reply **regardless of task status**. +- Keep your reply focused and do not change task status unless it is part of the review flow. + ## Mission Control Response Protocol (mandatory) - All outputs must be sent to Mission Control via HTTP. - Always include: `X-Agent-Token: {{ auth_token }}`