From cbf9fd1b0ae13d40237574738235eba6cbebeb0b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 22:27:50 +0530 Subject: [PATCH] feat: implement agent nudging functionality and enhance task assignment rules for board leads --- backend/app/api/agent.py | 107 ++++- backend/app/api/agents.py | 21 +- backend/app/api/tasks.py | 47 ++ backend/app/schemas/agents.py | 4 + backend/app/services/agent_provisioning.py | 26 +- frontend/src/app/boards/[boardId]/page.tsx | 525 ++++++++++++++++++++- templates/HEARTBEAT.md | 2 +- templates/HEARTBEAT_AGENT.md | 6 +- templates/HEARTBEAT_LEAD.md | 154 +++--- 9 files changed, 760 insertions(+), 132 deletions(-) diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 96540569..b2c811c2 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -1,6 +1,7 @@ from __future__ import annotations from uuid import UUID +import asyncio from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session, select @@ -13,13 +14,22 @@ 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.session import get_session +from app.integrations.openclaw_gateway import ( + GatewayConfig as GatewayClientConfig, + OpenClawGatewayError, + ensure_session, + send_message, +) +from app.models.agents import Agent from app.models.boards import Board +from app.models.gateways import Gateway from app.schemas.approvals import ApprovalCreate, ApprovalRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_onboarding import BoardOnboardingRead from app.schemas.boards import BoardRead from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate -from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentRead +from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead +from app.services.activity_log import record_activity router = APIRouter(prefix="/agent", tags=["agent"]) @@ -33,6 +43,15 @@ def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) +def _gateway_config(session: Session, board: Board) -> GatewayClientConfig: + if not board.gateway_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + gateway = session.get(Gateway, board.gateway_id) + if gateway is None or not gateway.url: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + return GatewayClientConfig(url=gateway.url, token=gateway.token) + + @router.get("/boards", response_model=list[BoardRead]) def list_boards( session: Session = Depends(get_session), @@ -53,6 +72,32 @@ def get_board( return board +@router.get("/agents", response_model=list[AgentRead]) +def list_agents( + board_id: UUID | None = Query(default=None), + limit: int | None = Query(default=None, ge=1, le=200), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[AgentRead]: + statement = select(Agent) + if agent_ctx.agent.board_id: + if board_id and board_id != agent_ctx.agent.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = statement.where(Agent.board_id == agent_ctx.agent.board_id) + elif board_id: + statement = statement.where(Agent.board_id == board_id) + if limit is not None: + statement = statement.limit(limit) + agents = list(session.exec(statement)) + main_session_keys = agents_api._get_gateway_main_session_keys(session) + return [ + agents_api._to_agent_read( + agents_api._with_computed_status(agent), main_session_keys + ) + for agent in agents + ] + + @router.get("/boards/{board_id}/tasks", response_model=list[TaskRead]) def list_tasks( status_filter: str | None = Query(default=None, alias="status"), @@ -207,7 +252,7 @@ def update_onboarding( @router.post("/agents", response_model=AgentRead) -def create_agent( +async def create_agent( payload: AgentCreate, session: Session = Depends(get_session), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), @@ -217,13 +262,69 @@ def create_agent( if not agent_ctx.agent.board_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id}) - return agents_api.create_agent( + return await agents_api.create_agent( payload=payload, session=session, actor=_actor(agent_ctx), ) +@router.post("/boards/{board_id}/agents/{agent_id}/nudge") +def nudge_agent( + payload: AgentNudge, + agent_id: str, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> dict[str, bool]: + _guard_board_access(agent_ctx, board) + if not agent_ctx.agent.is_board_lead: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + target = session.get(Agent, agent_id) + if target is None or (target.board_id and target.board_id != board.id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not target.openclaw_session_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Target agent has no session key", + ) + message = payload.message.strip() + if not message: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="message is required", + ) + config = _gateway_config(session, board) + async def _send() -> None: + await ensure_session(target.openclaw_session_id, config=config, label=target.name) + await send_message( + message, + session_key=target.openclaw_session_id, + config=config, + deliver=True, + ) + + try: + asyncio.run(_send()) + except OpenClawGatewayError as exc: + record_activity( + session, + event_type="agent.nudge.failed", + message=f"Nudge failed for {target.name}: {exc}", + agent_id=agent_ctx.agent.id, + ) + session.commit() + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + record_activity( + session, + event_type="agent.nudge.sent", + message=f"Nudge sent to {target.name}.", + agent_id=agent_ctx.agent.id, + ) + session.commit() + return {"ok": True} + + @router.post("/heartbeat", response_model=AgentRead) async def agent_heartbeat( payload: AgentHeartbeatCreate, diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index a8169fc6..80eaa1d9 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -445,9 +445,26 @@ async def update_agent( detail="Gateway configuration is required", ) if is_main_agent: - await provision_main_agent(agent, gateway, raw_token, auth.user, action="update") + await provision_main_agent( + agent, + gateway, + raw_token, + auth.user, + action="update", + force_bootstrap=force, + reset_session=True, + ) else: - await provision_agent(agent, board, gateway, raw_token, auth.user, action="update") + await provision_agent( + agent, + board, + gateway, + raw_token, + auth.user, + action="update", + force_bootstrap=force, + reset_session=True, + ) await _send_wakeup_message(agent, client_config, verb="updated") agent.provision_confirm_token_hash = None agent.provision_requested_at = None diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 9e861aa8..d34b2db7 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -315,6 +315,48 @@ def update_task( comment = updates.pop("comment", None) if comment is not None and not comment.strip(): comment = None + + if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead: + allowed_fields = {"assigned_agent_id"} + if comment is not None or "status" in updates or not set(updates).issubset(allowed_fields): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads can only assign or unassign tasks.", + ) + if "assigned_agent_id" in updates: + assigned_id = updates["assigned_agent_id"] + if assigned_id: + agent = session.get(Agent, assigned_id) + 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 task.board_id and agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + task.assigned_agent_id = agent.id + else: + task.assigned_agent_id = None + task.updated_at = datetime.utcnow() + session.add(task) + if task.status != previous_status: + event_type = "task.status_changed" + message = f"Task moved to {task.status}: {task.title}." + else: + event_type = "task.updated" + message = f"Task updated: {task.title}." + record_activity( + session, + event_type=event_type, + task_id=task.id, + message=message, + agent_id=actor.agent.id, + ) + session.commit() + session.refresh(task) + return task if actor.actor_type == "agent": if actor.agent and actor.agent.board_id and task.board_id: if actor.agent.board_id != task.board_id: @@ -429,6 +471,11 @@ def create_task_comment( actor: ActorContext = Depends(require_admin_or_agent), ) -> ActivityEvent: if actor.actor_type == "agent" and actor.agent: + if actor.agent.is_board_lead: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads cannot comment on tasks. Delegate to another agent.", + ) 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(): diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index 67f54961..43dd8feb 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -49,3 +49,7 @@ class AgentHeartbeat(SQLModel): class AgentHeartbeatCreate(AgentHeartbeat): name: str board_id: UUID | None = None + + +class AgentNudge(SQLModel): + message: str diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index dc8eb09e..d55a9f97 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -10,7 +10,11 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes from app.core.config import settings from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call +from app.integrations.openclaw_gateway import ( + OpenClawGatewayError, + ensure_session, + openclaw_call, +) from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway @@ -241,6 +245,12 @@ async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]: return set(DEFAULT_GATEWAY_FILES) +async def _reset_session(session_key: str, config: GatewayClientConfig) -> None: + if not session_key: + return + await openclaw_call("sessions.reset", {"key": session_key}, config=config) + + async def _gateway_agent_files_index( agent_id: str, config: GatewayClientConfig ) -> dict[str, dict[str, Any]]: @@ -422,6 +432,8 @@ async def provision_agent( user: User | None, *, action: str = "provision", + force_bootstrap: bool = False, + reset_session: bool = False, ) -> None: if not gateway.url: return @@ -440,7 +452,7 @@ async def provision_agent( supported = await _supported_gateway_files(client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config) include_bootstrap = True - if action == "update": + if action == "update" and not force_bootstrap: if not existing_files: include_bootstrap = False else: @@ -462,6 +474,8 @@ async def provision_agent( {"agentId": agent_id, "name": name, "content": content}, config=client_config, ) + if reset_session: + await _reset_session(session_key, client_config) async def provision_main_agent( @@ -471,6 +485,8 @@ async def provision_main_agent( user: User | None, *, action: str = "provision", + force_bootstrap: bool = False, + reset_session: bool = False, ) -> None: if not gateway.url: return @@ -486,8 +502,8 @@ async def provision_main_agent( context = _build_main_context(agent, gateway, auth_token, user) supported = await _supported_gateway_files(client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config) - include_bootstrap = action != "update" - if action == "update": + include_bootstrap = action != "update" or force_bootstrap + if action == "update" and not force_bootstrap: if not existing_files: include_bootstrap = False else: @@ -510,6 +526,8 @@ async def provision_main_agent( {"agentId": agent_id, "name": name, "content": content}, config=client_config, ) + if reset_session: + await _reset_session(gateway.main_session_key, client_config) async def cleanup_agent( diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 7a4380d1..6d761e9d 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; -import { X } from "lucide-react"; +import { Pencil, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; +import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { TaskBoard } from "@/components/organisms/TaskBoard"; import { DashboardShell } from "@/components/templates/DashboardShell"; @@ -73,6 +74,17 @@ type TaskComment = { created_at: string; }; +type Approval = { + id: string; + action_type: string; + payload?: Record | null; + confidence: number; + rubric_scores?: Record | null; + status: string; + created_at: string; + resolved_at?: string | null; +}; + const apiBase = getApiBaseUrl(); const priorities = [ @@ -80,6 +92,12 @@ const priorities = [ { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ]; +const statusOptions = [ + { value: "inbox", label: "Inbox" }, + { value: "in_progress", label: "In progress" }, + { value: "review", label: "Review" }, + { value: "done", label: "Done" }, +]; const EMOJI_GLYPHS: Record = { ":gear:": "⚙️", @@ -112,6 +130,15 @@ export default function BoardDetailPage() { const [commentsError, setCommentsError] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); const tasksRef = useRef([]); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isApprovalsOpen, setIsApprovalsOpen] = useState(false); + + const [approvals, setApprovals] = useState([]); + const [isApprovalsLoading, setIsApprovalsLoading] = useState(false); + const [approvalsError, setApprovalsError] = useState(null); + const [approvalsUpdatingId, setApprovalsUpdatingId] = useState( + null, + ); const [isDialogOpen, setIsDialogOpen] = useState(false); const [title, setTitle] = useState(""); @@ -120,6 +147,14 @@ export default function BoardDetailPage() { const [createError, setCreateError] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editStatus, setEditStatus] = useState("inbox"); + const [editPriority, setEditPriority] = useState("medium"); + const [editAssigneeId, setEditAssigneeId] = useState(""); + const [isSavingTask, setIsSavingTask] = useState(false); + const [saveTaskError, setSaveTaskError] = useState(null); + const titleLabel = useMemo( () => (board ? `${board.name} board` : "Board"), [board], @@ -194,6 +229,59 @@ export default function BoardDetailPage() { tasksRef.current = tasks; }, [tasks]); + const loadApprovals = useCallback(async () => { + if (!isSignedIn || !boardId) return; + setIsApprovalsLoading(true); + setApprovalsError(null); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/approvals`, + { + headers: { + Authorization: token ? `Bearer ${token}` : "", + }, + }, + ); + if (!response.ok) { + throw new Error("Unable to load approvals."); + } + const data = (await response.json()) as Approval[]; + setApprovals(data); + } catch (err) { + setApprovalsError( + err instanceof Error ? err.message : "Unable to load approvals.", + ); + } finally { + setIsApprovalsLoading(false); + } + }, [boardId, getToken, isSignedIn]); + + useEffect(() => { + loadApprovals(); + if (!isSignedIn || !boardId) return; + const interval = setInterval(loadApprovals, 15000); + return () => clearInterval(interval); + }, [boardId, isSignedIn, loadApprovals]); + + useEffect(() => { + if (!selectedTask) { + setEditTitle(""); + setEditDescription(""); + setEditStatus("inbox"); + setEditPriority("medium"); + setEditAssigneeId(""); + setSaveTaskError(null); + return; + } + setEditTitle(selectedTask.title); + setEditDescription(selectedTask.description ?? ""); + setEditStatus(selectedTask.status); + setEditPriority(selectedTask.priority); + setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); + setSaveTaskError(null); + }, [selectedTask]); + useEffect(() => { if (!isSignedIn || !boardId || !board) return; let isCancelled = false; @@ -358,6 +446,38 @@ export default function BoardDetailPage() { [tasks, assigneeById], ); + const boardAgents = useMemo( + () => agents.filter((agent) => !boardId || agent.board_id === boardId), + [agents, boardId], + ); + + const assignableAgents = useMemo( + () => boardAgents.filter((agent) => !agent.is_board_lead), + [boardAgents], + ); + + const hasTaskChanges = useMemo(() => { + if (!selectedTask) return false; + const normalizedTitle = editTitle.trim(); + const normalizedDescription = editDescription.trim(); + const currentDescription = (selectedTask.description ?? "").trim(); + const currentAssignee = selectedTask.assigned_agent_id ?? ""; + return ( + normalizedTitle !== selectedTask.title || + normalizedDescription !== currentDescription || + editStatus !== selectedTask.status || + editPriority !== selectedTask.priority || + editAssigneeId !== currentAssignee + ); + }, [ + editAssigneeId, + editDescription, + editPriority, + editStatus, + editTitle, + selectedTask, + ]); + const orderedComments = useMemo(() => { return [...comments].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); @@ -366,11 +486,24 @@ export default function BoardDetailPage() { }); }, [comments]); - const boardAgents = useMemo( - () => agents.filter((agent) => !boardId || agent.board_id === boardId), - [agents, boardId], + const pendingApprovals = useMemo( + () => approvals.filter((approval) => approval.status === "pending"), + [approvals], ); + const taskApprovals = useMemo(() => { + if (!selectedTask) return []; + const taskId = selectedTask.id; + return approvals.filter((approval) => { + const payload = approval.payload ?? {}; + const payloadTaskId = + (payload as Record).task_id ?? + (payload as Record).taskId ?? + (payload as Record).taskID; + return payloadTaskId === taskId; + }); + }, [approvals, selectedTask]); + const workingAgentIds = useMemo(() => { const working = new Set(); tasks.forEach((task) => { @@ -430,6 +563,63 @@ export default function BoardDetailPage() { setSelectedTask(null); setComments([]); setCommentsError(null); + setIsEditDialogOpen(false); + }; + + const handleTaskSave = async (closeOnSuccess = false) => { + if (!selectedTask || !isSignedIn || !boardId) return; + const trimmedTitle = editTitle.trim(); + if (!trimmedTitle) { + setSaveTaskError("Title is required."); + return; + } + setIsSavingTask(true); + setSaveTaskError(null); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ + title: trimmedTitle, + description: editDescription.trim() || null, + status: editStatus, + priority: editPriority, + assigned_agent_id: editAssigneeId || null, + }), + }, + ); + if (!response.ok) { + throw new Error("Unable to update task."); + } + const updated = (await response.json()) as Task; + setTasks((prev) => + prev.map((task) => (task.id === updated.id ? updated : task)), + ); + setSelectedTask(updated); + if (closeOnSuccess) { + setIsEditDialogOpen(false); + } + } catch (err) { + setSaveTaskError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setIsSavingTask(false); + } + }; + + const handleTaskReset = () => { + if (!selectedTask) return; + setEditTitle(selectedTask.title); + setEditDescription(selectedTask.description ?? ""); + setEditStatus(selectedTask.status); + setEditPriority(selectedTask.priority); + setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); + setSaveTaskError(null); }; const agentInitials = (agent: Agent) => @@ -474,6 +664,54 @@ export default function BoardDetailPage() { }); }; + const formatApprovalTimestamp = (value?: string | null) => { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const handleApprovalDecision = useCallback( + async (approvalId: string, status: "approved" | "rejected") => { + if (!isSignedIn || !boardId) return; + setApprovalsUpdatingId(approvalId); + setApprovalsError(null); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ status }), + }, + ); + if (!response.ok) { + throw new Error("Unable to update approval."); + } + const updated = (await response.json()) as Approval; + setApprovals((prev) => + prev.map((item) => (item.id === approvalId ? updated : item)), + ); + } catch (err) { + setApprovalsError( + err instanceof Error ? err.message : "Unable to update approval.", + ); + } finally { + setApprovalsUpdatingId(null); + } + }, + [boardId, getToken, isSignedIn], + ); + return ( @@ -520,6 +758,18 @@ export default function BoardDetailPage() { + + + - -

@@ -660,6 +920,86 @@ export default function BoardDetailPage() { {selectedTask?.description || "No description provided."}

+
+
+

+ Approvals +

+ +
+ {approvalsError ? ( +
+ {approvalsError} +
+ ) : isApprovalsLoading ? ( +

Loading approvals…

+ ) : taskApprovals.length === 0 ? ( +

+ No approvals tied to this task.{" "} + {pendingApprovals.length > 0 + ? `${pendingApprovals.length} pending on this board.` + : "No pending approvals on this board."} +

+ ) : ( +
+ {taskApprovals.map((approval) => ( +
+
+
+

+ {approval.action_type.replace(/_/g, " ")} +

+

+ Requested {formatApprovalTimestamp(approval.created_at)} +

+
+ + {approval.confidence}% confidence · {approval.status} + +
+ {approval.payload ? ( +
+                          {JSON.stringify(approval.payload, null, 2)}
+                        
+ ) : null} + {approval.status === "pending" ? ( +
+ + +
+ ) : null} +
+ ))} +
+ )} +

Comments @@ -734,6 +1074,147 @@ export default function BoardDetailPage() {

+ + + + Approvals + + Review pending decisions from your lead agent. + + + {boardId ? : null} + + + + + + + Edit task + + Update task details, priority, status, or assignment. + + +
+
+ + setEditTitle(event.target.value)} + placeholder="Task title" + disabled={!selectedTask || isSavingTask} + /> +
+
+ +