feat: implement agent nudging functionality and enhance task assignment rules for board leads

This commit is contained in:
Abhimanyu Saharan
2026-02-05 22:27:50 +05:30
parent 77e37f73b3
commit cbf9fd1b0a
9 changed files with 760 additions and 132 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from uuid import UUID from uuid import UUID
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select 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.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.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.db.session import get_session 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.boards import Board
from app.models.gateways import Gateway
from app.schemas.approvals import ApprovalCreate, ApprovalRead from app.schemas.approvals import ApprovalCreate, ApprovalRead
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingRead from app.schemas.board_onboarding import BoardOnboardingRead
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate 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"]) 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) 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]) @router.get("/boards", response_model=list[BoardRead])
def list_boards( def list_boards(
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -53,6 +72,32 @@ def get_board(
return 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]) @router.get("/boards/{board_id}/tasks", response_model=list[TaskRead])
def list_tasks( def list_tasks(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
@@ -207,7 +252,7 @@ def update_onboarding(
@router.post("/agents", response_model=AgentRead) @router.post("/agents", response_model=AgentRead)
def create_agent( async def create_agent(
payload: AgentCreate, payload: AgentCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
@@ -217,13 +262,69 @@ def create_agent(
if not agent_ctx.agent.board_id: if not agent_ctx.agent.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id}) 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, payload=payload,
session=session, session=session,
actor=_actor(agent_ctx), 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) @router.post("/heartbeat", response_model=AgentRead)
async def agent_heartbeat( async def agent_heartbeat(
payload: AgentHeartbeatCreate, payload: AgentHeartbeatCreate,

View File

@@ -445,9 +445,26 @@ async def update_agent(
detail="Gateway configuration is required", detail="Gateway configuration is required",
) )
if is_main_agent: 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: 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") await _send_wakeup_message(agent, client_config, verb="updated")
agent.provision_confirm_token_hash = None agent.provision_confirm_token_hash = None
agent.provision_requested_at = None agent.provision_requested_at = None

View File

@@ -315,6 +315,48 @@ def update_task(
comment = updates.pop("comment", None) comment = updates.pop("comment", None)
if comment is not None and not comment.strip(): if comment is not None and not comment.strip():
comment = None 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.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id: if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != 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), actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent: ) -> ActivityEvent:
if actor.actor_type == "agent" and actor.agent: 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: 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) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip(): if not payload.message.strip():

View File

@@ -49,3 +49,7 @@ class AgentHeartbeat(SQLModel):
class AgentHeartbeatCreate(AgentHeartbeat): class AgentHeartbeatCreate(AgentHeartbeat):
name: str name: str
board_id: UUID | None = None board_id: UUID | None = None
class AgentNudge(SQLModel):
message: str

View File

@@ -10,7 +10,11 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
from app.core.config import settings from app.core.config import settings
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig 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.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
@@ -241,6 +245,12 @@ async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]:
return set(DEFAULT_GATEWAY_FILES) 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( async def _gateway_agent_files_index(
agent_id: str, config: GatewayClientConfig agent_id: str, config: GatewayClientConfig
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
@@ -422,6 +432,8 @@ async def provision_agent(
user: User | None, user: User | None,
*, *,
action: str = "provision", action: str = "provision",
force_bootstrap: bool = False,
reset_session: bool = False,
) -> None: ) -> None:
if not gateway.url: if not gateway.url:
return return
@@ -440,7 +452,7 @@ async def provision_agent(
supported = await _supported_gateway_files(client_config) supported = await _supported_gateway_files(client_config)
existing_files = await _gateway_agent_files_index(agent_id, client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = True include_bootstrap = True
if action == "update": if action == "update" and not force_bootstrap:
if not existing_files: if not existing_files:
include_bootstrap = False include_bootstrap = False
else: else:
@@ -462,6 +474,8 @@ async def provision_agent(
{"agentId": agent_id, "name": name, "content": content}, {"agentId": agent_id, "name": name, "content": content},
config=client_config, config=client_config,
) )
if reset_session:
await _reset_session(session_key, client_config)
async def provision_main_agent( async def provision_main_agent(
@@ -471,6 +485,8 @@ async def provision_main_agent(
user: User | None, user: User | None,
*, *,
action: str = "provision", action: str = "provision",
force_bootstrap: bool = False,
reset_session: bool = False,
) -> None: ) -> None:
if not gateway.url: if not gateway.url:
return return
@@ -486,8 +502,8 @@ async def provision_main_agent(
context = _build_main_context(agent, gateway, auth_token, user) context = _build_main_context(agent, gateway, auth_token, user)
supported = await _supported_gateway_files(client_config) supported = await _supported_gateway_files(client_config)
existing_files = await _gateway_agent_files_index(agent_id, client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = action != "update" include_bootstrap = action != "update" or force_bootstrap
if action == "update": if action == "update" and not force_bootstrap:
if not existing_files: if not existing_files:
include_bootstrap = False include_bootstrap = False
else: else:
@@ -510,6 +526,8 @@ async def provision_main_agent(
{"agentId": agent_id, "name": name, "content": content}, {"agentId": agent_id, "name": name, "content": content},
config=client_config, config=client_config,
) )
if reset_session:
await _reset_session(gateway.main_session_key, client_config)
async def cleanup_agent( async def cleanup_agent(

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; 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 ReactMarkdown from "react-markdown";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard"; import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -73,6 +74,17 @@ type TaskComment = {
created_at: string; created_at: string;
}; };
type Approval = {
id: string;
action_type: string;
payload?: Record<string, unknown> | null;
confidence: number;
rubric_scores?: Record<string, number> | null;
status: string;
created_at: string;
resolved_at?: string | null;
};
const apiBase = getApiBaseUrl(); const apiBase = getApiBaseUrl();
const priorities = [ const priorities = [
@@ -80,6 +92,12 @@ const priorities = [
{ value: "medium", label: "Medium" }, { value: "medium", label: "Medium" },
{ value: "high", label: "High" }, { 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<string, string> = { const EMOJI_GLYPHS: Record<string, string> = {
":gear:": "⚙️", ":gear:": "⚙️",
@@ -112,6 +130,15 @@ export default function BoardDetailPage() {
const [commentsError, setCommentsError] = useState<string | null>(null); const [commentsError, setCommentsError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]); const tasksRef = useRef<Task[]>([]);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
const [approvalsError, setApprovalsError] = useState<string | null>(null);
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
null,
);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -120,6 +147,14 @@ export default function BoardDetailPage() {
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); 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<string | null>(null);
const titleLabel = useMemo( const titleLabel = useMemo(
() => (board ? `${board.name} board` : "Board"), () => (board ? `${board.name} board` : "Board"),
[board], [board],
@@ -194,6 +229,59 @@ export default function BoardDetailPage() {
tasksRef.current = tasks; tasksRef.current = tasks;
}, [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(() => { useEffect(() => {
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
let isCancelled = false; let isCancelled = false;
@@ -358,6 +446,38 @@ export default function BoardDetailPage() {
[tasks, assigneeById], [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(() => { const orderedComments = useMemo(() => {
return [...comments].sort((a, b) => { return [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = new Date(a.created_at).getTime();
@@ -366,11 +486,24 @@ export default function BoardDetailPage() {
}); });
}, [comments]); }, [comments]);
const boardAgents = useMemo( const pendingApprovals = useMemo(
() => agents.filter((agent) => !boardId || agent.board_id === boardId), () => approvals.filter((approval) => approval.status === "pending"),
[agents, boardId], [approvals],
); );
const taskApprovals = useMemo(() => {
if (!selectedTask) return [];
const taskId = selectedTask.id;
return approvals.filter((approval) => {
const payload = approval.payload ?? {};
const payloadTaskId =
(payload as Record<string, unknown>).task_id ??
(payload as Record<string, unknown>).taskId ??
(payload as Record<string, unknown>).taskID;
return payloadTaskId === taskId;
});
}, [approvals, selectedTask]);
const workingAgentIds = useMemo(() => { const workingAgentIds = useMemo(() => {
const working = new Set<string>(); const working = new Set<string>();
tasks.forEach((task) => { tasks.forEach((task) => {
@@ -430,6 +563,63 @@ export default function BoardDetailPage() {
setSelectedTask(null); setSelectedTask(null);
setComments([]); setComments([]);
setCommentsError(null); 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) => 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 ( return (
<DashboardShell> <DashboardShell>
<SignedOut> <SignedOut>
@@ -520,6 +758,18 @@ export default function BoardDetailPage() {
<Button onClick={() => setIsDialogOpen(true)}> <Button onClick={() => setIsDialogOpen(true)}>
New task New task
</Button> </Button>
<Button
variant="outline"
onClick={() => setIsApprovalsOpen(true)}
className="relative"
>
Approvals
{pendingApprovals.length > 0 ? (
<span className="ml-2 inline-flex min-w-[20px] items-center justify-center rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white">
{pendingApprovals.length}
</span>
) : null}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => router.push(`/boards/${boardId}/edit`)} onClick={() => router.push(`/boards/${boardId}/edit`)}
@@ -643,6 +893,15 @@ export default function BoardDetailPage() {
{selectedTask?.title ?? "Task"} {selectedTask?.title ?? "Task"}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsEditDialogOpen(true)}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
disabled={!selectedTask}
>
<Pencil className="h-4 w-4" />
</button>
<button <button
type="button" type="button"
onClick={closeComments} onClick={closeComments}
@@ -651,6 +910,7 @@ export default function BoardDetailPage() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
</div>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5"> <div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -660,6 +920,86 @@ export default function BoardDetailPage() {
{selectedTask?.description || "No description provided."} {selectedTask?.description || "No description provided."}
</p> </p>
</div> </div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Approvals
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsApprovalsOpen(true)}
>
View all
</Button>
</div>
{approvalsError ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
{approvalsError}
</div>
) : isApprovalsLoading ? (
<p className="text-sm text-slate-500">Loading approvals</p>
) : taskApprovals.length === 0 ? (
<p className="text-sm text-slate-500">
No approvals tied to this task.{" "}
{pendingApprovals.length > 0
? `${pendingApprovals.length} pending on this board.`
: "No pending approvals on this board."}
</p>
) : (
<div className="space-y-3">
{taskApprovals.map((approval) => (
<div
key={approval.id}
className="rounded-xl border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{approval.action_type.replace(/_/g, " ")}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested {formatApprovalTimestamp(approval.created_at)}
</p>
</div>
<span className="text-xs font-semibold text-slate-700">
{approval.confidence}% confidence · {approval.status}
</span>
</div>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-slate-600">
{JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.status === "pending" ? (
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
onClick={() =>
handleApprovalDecision(approval.id, "approved")
}
disabled={approvalsUpdatingId === approval.id}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleApprovalDecision(approval.id, "rejected")
}
disabled={approvalsUpdatingId === approval.id}
className="border-slate-300 text-slate-700"
>
Reject
</Button>
</div>
) : null}
</div>
))}
</div>
)}
</div>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Comments Comments
@@ -734,6 +1074,147 @@ export default function BoardDetailPage() {
</div> </div>
</aside> </aside>
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}>
<DialogContent aria-label="Approvals">
<DialogHeader>
<DialogTitle>Approvals</DialogTitle>
<DialogDescription>
Review pending decisions from your lead agent.
</DialogDescription>
</DialogHeader>
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</DialogContent>
</Dialog>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent aria-label="Edit task">
<DialogHeader>
<DialogTitle>Edit task</DialogTitle>
<DialogDescription>
Update task details, priority, status, or assignment.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Title
</label>
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder="Task title"
disabled={!selectedTask || isSavingTask}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Description
</label>
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Task details"
className="min-h-[140px]"
disabled={!selectedTask || isSavingTask}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Status
</label>
<Select
value={editStatus}
onValueChange={setEditStatus}
disabled={!selectedTask || isSavingTask}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Priority
</label>
<Select
value={editPriority}
onValueChange={setEditPriority}
disabled={!selectedTask || isSavingTask}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Assignee
</label>
<Select
value={editAssigneeId || "unassigned"}
onValueChange={(value) =>
setEditAssigneeId(value === "unassigned" ? "" : value)
}
disabled={!selectedTask || isSavingTask}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{assignableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
{assignableAgents.length === 0 ? (
<p className="text-xs text-slate-500">
Add agents to assign tasks.
</p>
) : null}
</div>
{saveTaskError ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
{saveTaskError}
</div>
) : null}
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={handleTaskReset}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
>
Reset
</Button>
<Button
onClick={() => handleTaskSave(true)}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
>
{isSavingTask ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog <Dialog
open={isDialogOpen} open={isDialogOpen}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {

View File

@@ -1,6 +1,6 @@
# HEARTBEAT.md # HEARTBEAT.md
> This file is provisioned from HEARTBEAT_LEAD.md or HEARTBEAT_AGENT.md. If you see this template directly, follow the agent loop below. > This file is provisioned per-agent. Follow the loop below if you see this directly.
## Purpose ## Purpose
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly. This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.

View File

@@ -1,4 +1,4 @@
# HEARTBEAT_AGENT.md # HEARTBEAT.md
## Purpose ## Purpose
This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly. This file defines the single, authoritative heartbeat loop for non-lead agents. Follow it exactly.
@@ -29,11 +29,11 @@ If any required input is missing, stop and request a provisioning update.
## Preflight checks (before each heartbeat) ## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access: - Verify API access (do NOT assume last heartbeat outcome):
- GET $BASE_URL/healthz must succeed. - GET $BASE_URL/healthz must succeed.
- GET $BASE_URL/api/v1/agent/boards must succeed. - GET $BASE_URL/api/v1/agent/boards must succeed.
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed. - GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
- If any check fails, stop and retry next heartbeat. - If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
## Heartbeat checklist (run in order) ## Heartbeat checklist (run in order)
1) Check in: 1) Check in:

View File

@@ -1,7 +1,8 @@
# HEARTBEAT_LEAD.md # HEARTBEAT.md
## Purpose ## Purpose
This file defines the single, authoritative heartbeat loop for the board lead agent. Follow it exactly. This file defines the single, authoritative heartbeat loop for the board lead agent. Follow it exactly.
You are the lead agent for this board. You delegate work; you do not execute tasks.
## Required inputs ## Required inputs
- BASE_URL (e.g. http://localhost:8000) - BASE_URL (e.g. http://localhost:8000)
@@ -17,10 +18,10 @@ If any required input is missing, stop and request a provisioning update.
- On first boot, send one immediate check-in before the schedule starts. - On first boot, send one immediate check-in before the schedule starts.
## Nonnegotiable rules ## Nonnegotiable rules
- Task updates go only to task comments (never chat/web). - The lead agent must **never** work a task directly.
- Comments must be markdown. Write naturally; be clear and concise. - Do **not** claim tasks or post task comments.
- Every status change must have a comment within 30 seconds. - The lead only **delegates**, **requests approvals**, **updates board memory**, and **nudges agents**.
- Do not claim a new task if you already have one in progress. - All outputs must go to Mission Control via HTTP (never chat/web).
## Mission Control Response Protocol (mandatory) ## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP. - All outputs must be sent to Mission Control via HTTP.
@@ -29,13 +30,13 @@ If any required input is missing, stop and request a provisioning update.
## Preflight checks (before each heartbeat) ## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access: - Verify API access (do NOT assume last heartbeat outcome):
- GET $BASE_URL/healthz must succeed. - GET $BASE_URL/healthz must succeed.
- GET $BASE_URL/api/v1/agent/boards must succeed. - GET $BASE_URL/api/v1/agent/boards must succeed.
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed. - GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks must succeed.
- If any check fails, stop and retry next heartbeat. - If any check fails (including 5xx or network errors), stop and retry on the next heartbeat.
## Board Lead Loop (run every heartbeat before claiming work) ## Board Lead Loop (run every heartbeat)
1) Read board goal context: 1) Read board goal context:
- Board: {{ board_name }} ({{ board_type }}) - Board: {{ board_name }} ({{ board_type }})
- Objective: {{ board_objective }} - Objective: {{ board_objective }}
@@ -52,30 +53,27 @@ If any required input is missing, stop and request a provisioning update.
4) Identify missing steps, blockers, and specialists needed. 4) Identify missing steps, blockers, and specialists needed.
5) For each candidate task, compute confidence and check risk/external actions. 4a) Monitor in-progress tasks and nudge owners if stalled:
Confidence rubric (max 100): - For each in_progress task assigned to another agent, check for a recent comment/update.
- clarity 25 - If no comment in the last 60 minutes, send a nudge (do NOT comment on the task).
- constraints 20 Nudge endpoint:
- completeness 15 POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/agents/{AGENT_ID}/nudge
- risk 20 Body: {"message":"Friendly reminder to post an update on TASK_ID ..."}
- dependencies 10
- similarity 10
If risky/external OR confidence < 80: 5) Delegate inbox work (never do it yourself):
- POST approval request to $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals - Pick the best nonlead agent (or create one if missing).
Body example: - Assign the task to that agent (do NOT change status).
{"action_type":"task.create","confidence":75,"payload":{"title":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}} - Never assign a task to yourself.
Assign endpoint (leadallowed):
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"assigned_agent_id":"AGENT_ID"}
Else: 6) Create agents only when needed:
- Create the task and assign an agent. - If workload or skills coverage is insufficient, create a new agent.
- Rule: you may autocreate agents only when confidence >= 70 and the action is not risky/external.
6) If workload or skills coverage is insufficient, create new agents. - If risky/external or confidence < 70, create an approval instead.
Rule: you may autocreate agents only when confidence >= 80 and the action is not risky/external. Agent create (leadallowed):
If the action is risky/external or confidence < 80, create an approval instead. POST $BASE_URL/api/v1/agent/agents
Agent create (lead-only):
- POST $BASE_URL/api/v1/agent/agents
Headers: X-Agent-Token: {{ auth_token }}
Body example: Body example:
{ {
"name": "Researcher Alpha", "name": "Researcher Alpha",
@@ -87,10 +85,14 @@ If any required input is missing, stop and request a provisioning update.
} }
} }
Approval example: 7) Creating new tasks:
{"action_type":"agent.create","confidence":70,"payload":{"role":"Research","reason":"Need specialist"}} - Leads cannot create tasks directly (adminonly).
- If a new task is needed, request approval:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example:
{"action_type":"task.create","confidence":75,"payload":{"title":"...","description":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
7) Post a brief status update in board memory (1-3 bullets). 8) Post a brief status update in board memory (1-3 bullets).
## Heartbeat checklist (run in order) ## Heartbeat checklist (run in order)
1) Check in: 1) Check in:
@@ -101,15 +103,9 @@ curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}' -d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
``` ```
2) List boards: 2) For the assigned board, list tasks (use filters to avoid large responses):
```bash ```bash
curl -s "$BASE_URL/api/v1/agent/boards" \ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
3) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
-H "X-Agent-Token: {{ auth_token }}" -H "X-Agent-Token: {{ auth_token }}"
``` ```
```bash ```bash
@@ -117,53 +113,17 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
-H "X-Agent-Token: {{ auth_token }}" -H "X-Agent-Token: {{ auth_token }}"
``` ```
4) If you already have an in_progress task, continue working it and do not claim another. 3) If inbox tasks exist, **delegate** them:
- Identify the best nonlead agent (or create one).
5) If you do NOT have an in_progress task, claim one inbox task: - Assign the task (do not change status).
- Move it to in_progress AND add a markdown comment describing the update. - Never claim or work the task yourself.
6) Work the task:
- Post progress comments as you go.
- Completion is a twostep sequence:
6a) Post the full response as a markdown comment using:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
Example:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"message":"- Update: ...\n- Result: ..."}'
```
6b) Move the task to review.
6b) Move the task to "review":
```bash
curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "X-Agent-Token: {{ auth_token }}" \
-H "Content-Type: application/json" \
-d '{"status": "review"}'
```
## Definition of Done ## Definition of Done
- A task is not complete until the draft/response is posted as a task comment. - Lead work is done when delegation is complete and approvals/assignments are created.
- Comments must be markdown.
## Common mistakes (avoid) ## Common mistakes (avoid)
- Changing status without posting a comment. - Claiming or working tasks as the lead.
- Posting updates in chat/web instead of task comments. - Posting task comments.
- Claiming a second task while one is already in progress. - Assigning a task to yourself.
- Moving to review before posting the full response. - Marking tasks review/done (lead cannot).
- Sending Authorization header instead of X-Agent-Token. - Using nonagent endpoints or Authorization header.
## Success criteria (when to say HEARTBEAT_OK)
- Checkin succeeded.
- Tasks were listed successfully.
- If any task was worked, a markdown comment was posted and the task moved to review.
- If any task is inbox or in_progress, do NOT say HEARTBEAT_OK.
## Status flow
```
inbox -> in_progress -> review -> done
```
Do not say HEARTBEAT_OK if there is inbox work or active in_progress work.