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 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,

View File

@@ -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

View File

@@ -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():

View File

@@ -49,3 +49,7 @@ class AgentHeartbeat(SQLModel):
class AgentHeartbeatCreate(AgentHeartbeat):
name: str
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.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(

View File

@@ -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<string, unknown> | null;
confidence: number;
rubric_scores?: Record<string, number> | 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<string, string> = {
":gear:": "⚙️",
@@ -112,6 +130,15 @@ export default function BoardDetailPage() {
const [commentsError, setCommentsError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
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 [title, setTitle] = useState("");
@@ -120,6 +147,14 @@ export default function BoardDetailPage() {
const [createError, setCreateError] = useState<string | null>(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<string | null>(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<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 working = new Set<string>();
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 (
<DashboardShell>
<SignedOut>
@@ -520,6 +758,18 @@ export default function BoardDetailPage() {
<Button onClick={() => setIsDialogOpen(true)}>
New task
</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
variant="outline"
onClick={() => router.push(`/boards/${boardId}/edit`)}
@@ -633,24 +883,34 @@ export default function BoardDetailPage() {
isDetailOpen ? "translate-x-0" : "translate-x-full",
)}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Task detail
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{selectedTask?.title ?? "Task"}
</p>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Task detail
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{selectedTask?.title ?? "Task"}
</p>
</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
type="button"
onClick={closeComments}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<button
type="button"
onClick={closeComments}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
<div className="space-y-2">
<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."}
</p>
</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">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Comments
@@ -734,6 +1074,147 @@ export default function BoardDetailPage() {
</div>
</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
open={isDialogOpen}
onOpenChange={(nextOpen) => {

View File

@@ -1,6 +1,6 @@
# 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
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
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)
- 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/api/v1/agent/boards 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)
1) Check in:

View File

@@ -1,7 +1,8 @@
# HEARTBEAT_LEAD.md
# HEARTBEAT.md
## Purpose
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
- 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.
## Nonnegotiable rules
- Task updates go only to task comments (never chat/web).
- Comments must be markdown. Write naturally; be clear and concise.
- Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress.
- The lead agent must **never** work a task directly.
- Do **not** claim tasks or post task comments.
- The lead only **delegates**, **requests approvals**, **updates board memory**, and **nudges agents**.
- All outputs must go to Mission Control via HTTP (never chat/web).
## Mission Control Response Protocol (mandatory)
- 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)
- 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/api/v1/agent/boards 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:
- Board: {{ board_name }} ({{ board_type }})
- Objective: {{ board_objective }}
@@ -52,45 +53,46 @@ If any required input is missing, stop and request a provisioning update.
4) Identify missing steps, blockers, and specialists needed.
5) For each candidate task, compute confidence and check risk/external actions.
Confidence rubric (max 100):
- clarity 25
- constraints 20
- completeness 15
- risk 20
- dependencies 10
- similarity 10
4a) Monitor in-progress tasks and nudge owners if stalled:
- For each in_progress task assigned to another agent, check for a recent comment/update.
- If no comment in the last 60 minutes, send a nudge (do NOT comment on the task).
Nudge endpoint:
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/agents/{AGENT_ID}/nudge
Body: {"message":"Friendly reminder to post an update on TASK_ID ..."}
If risky/external OR confidence < 80:
- POST approval request to $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example:
{"action_type":"task.create","confidence":75,"payload":{"title":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
5) Delegate inbox work (never do it yourself):
- Pick the best nonlead agent (or create one if missing).
- Assign the task to that agent (do NOT change status).
- 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:
- Create the task and assign an agent.
6) Create agents only when needed:
- 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.
- If risky/external or confidence < 70, create an approval instead.
Agent create (leadallowed):
POST $BASE_URL/api/v1/agent/agents
Body example:
{
"name": "Researcher Alpha",
"board_id": "{BOARD_ID}",
"identity_profile": {
"role": "Research",
"communication_style": "concise, structured",
"emoji": ":brain:"
}
}
6) If workload or skills coverage is insufficient, create new agents.
Rule: you may autocreate agents only when confidence >= 80 and the action is not risky/external.
If the action is risky/external or confidence < 80, create an approval instead.
7) Creating new tasks:
- 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}}
Agent create (lead-only):
- POST $BASE_URL/api/v1/agent/agents
Headers: X-Agent-Token: {{ auth_token }}
Body example:
{
"name": "Researcher Alpha",
"board_id": "{BOARD_ID}",
"identity_profile": {
"role": "Research",
"communication_style": "concise, structured",
"emoji": ":brain:"
}
}
Approval example:
{"action_type":"agent.create","confidence":70,"payload":{"role":"Research","reason":"Need specialist"}}
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)
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"}'
```
2) List boards:
2) For the assigned board, list tasks (use filters to avoid large responses):
```bash
curl -s "$BASE_URL/api/v1/agent/boards" \
-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" \
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
-H "X-Agent-Token: {{ auth_token }}"
```
```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 }}"
```
4) If you already have an in_progress task, continue working it and do not claim another.
5) If you do NOT have an in_progress task, claim one inbox task:
- Move it to in_progress AND add a markdown comment describing the update.
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"}'
```
3) If inbox tasks exist, **delegate** them:
- Identify the best nonlead agent (or create one).
- Assign the task (do not change status).
- Never claim or work the task yourself.
## Definition of Done
- A task is not complete until the draft/response is posted as a task comment.
- Comments must be markdown.
- Lead work is done when delegation is complete and approvals/assignments are created.
## Common mistakes (avoid)
- Changing status without posting a comment.
- Posting updates in chat/web instead of task comments.
- Claiming a second task while one is already in progress.
- Moving to review before posting the full response.
- Sending Authorization header instead of X-Agent-Token.
## 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.
- Claiming or working tasks as the lead.
- Posting task comments.
- Assigning a task to yourself.
- Marking tasks review/done (lead cannot).
- Using nonagent endpoints or Authorization header.