feat: implement agent nudging functionality and enhance task assignment rules for board leads
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -49,3 +49,7 @@ class AgentHeartbeat(SQLModel):
|
||||
class AgentHeartbeatCreate(AgentHeartbeat):
|
||||
name: str
|
||||
board_id: UUID | None = None
|
||||
|
||||
|
||||
class AgentNudge(SQLModel):
|
||||
message: str
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Pre‑flight 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Non‑negotiable 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.
|
||||
|
||||
## Pre‑flight 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 non‑lead agent (or create one if missing).
|
||||
- Assign the task to that agent (do NOT change status).
|
||||
- Never assign a task to yourself.
|
||||
Assign endpoint (lead‑allowed):
|
||||
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 auto‑create agents only when confidence >= 70 and the action is not risky/external.
|
||||
- If risky/external or confidence < 70, create an approval instead.
|
||||
Agent create (lead‑allowed):
|
||||
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 auto‑create 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 (admin‑only).
|
||||
- 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 two‑step 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 non‑lead 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)
|
||||
- Check‑in 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 non‑agent endpoints or Authorization header.
|
||||
|
||||
Reference in New Issue
Block a user