feat: add is_board_lead property to agent and board types
This commit is contained in:
239
backend/app/api/agent.py
Normal file
239
backend/app/api/agent.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import agents as agents_api
|
||||
from app.api import approvals as approvals_api
|
||||
from app.api import board_memory as board_memory_api
|
||||
from app.api import board_onboarding as onboarding_api
|
||||
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.models.boards import Board
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
|
||||
return ActorContext(actor_type="agent", agent=agent_ctx.agent)
|
||||
|
||||
|
||||
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
|
||||
if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@router.get("/boards", response_model=list[BoardRead])
|
||||
def list_boards(
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[Board]:
|
||||
if agent_ctx.agent.board_id:
|
||||
board = session.get(Board, agent_ctx.agent.board_id)
|
||||
return [board] if board else []
|
||||
return list(session.exec(select(Board)))
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}", response_model=BoardRead)
|
||||
def get_board(
|
||||
board: Board = Depends(get_board_or_404),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> Board:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tasks", response_model=list[TaskRead])
|
||||
def list_tasks(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
assigned_agent_id: UUID | None = None,
|
||||
unassigned: bool | None = None,
|
||||
limit: int | None = Query(default=None, ge=1, le=200),
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[TaskRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return tasks_api.list_tasks(
|
||||
status_filter=status_filter,
|
||||
assigned_agent_id=assigned_agent_id,
|
||||
unassigned=unassigned,
|
||||
limit=limit,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
|
||||
def update_task(
|
||||
payload: TaskUpdate,
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> TaskRead:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.update_task(
|
||||
payload=payload,
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tasks/{task_id}/comments", response_model=list[TaskCommentRead])
|
||||
def list_task_comments(
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[TaskCommentRead]:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.list_task_comments(
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead)
|
||||
def create_task_comment(
|
||||
payload: TaskCommentCreate,
|
||||
task=Depends(get_task_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> TaskCommentRead:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tasks_api.create_task_comment(
|
||||
payload=payload,
|
||||
task=task,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/memory", response_model=list[BoardMemoryRead])
|
||||
def list_board_memory(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[BoardMemoryRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board_memory_api.list_board_memory(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead)
|
||||
def create_board_memory(
|
||||
payload: BoardMemoryCreate,
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> BoardMemoryRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board_memory_api.create_board_memory(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/approvals", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> list[ApprovalRead]:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return approvals_api.list_approvals(
|
||||
status_filter=status_filter,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead)
|
||||
def create_approval(
|
||||
payload: ApprovalCreate,
|
||||
board=Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> ApprovalRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return approvals_api.create_approval(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead)
|
||||
def update_onboarding(
|
||||
payload: dict[str, object],
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> BoardOnboardingRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return onboarding_api.agent_onboarding_update(
|
||||
payload=payload,
|
||||
board=board,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents", response_model=AgentRead)
|
||||
def create_agent(
|
||||
payload: AgentCreate,
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> AgentRead:
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
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(
|
||||
payload=payload,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=AgentRead)
|
||||
async def agent_heartbeat(
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> AgentRead:
|
||||
if agent_ctx.agent.name != payload.name:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
|
||||
payload=payload,
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
@@ -301,6 +301,7 @@ def get_agent(
|
||||
async def update_agent(
|
||||
agent_id: str,
|
||||
payload: AgentUpdate,
|
||||
force: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
) -> Agent:
|
||||
@@ -321,7 +322,7 @@ async def update_agent(
|
||||
updates["identity_profile"] = _normalize_identity_profile(
|
||||
updates.get("identity_profile")
|
||||
)
|
||||
if not updates:
|
||||
if not updates and not force:
|
||||
return _with_computed_status(agent)
|
||||
if "board_id" in updates:
|
||||
_require_board(session, updates["board_id"])
|
||||
@@ -380,9 +381,17 @@ async def update_agent(
|
||||
except OpenClawGatewayError as exc:
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gateway update failed: {exc}",
|
||||
) from exc
|
||||
except Exception as exc: # pragma: no cover - unexpected provisioning errors
|
||||
_record_instruction_failure(session, agent, str(exc), "update")
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Unexpected error updating agent provisioning.",
|
||||
) from exc
|
||||
return _with_computed_status(agent)
|
||||
|
||||
|
||||
|
||||
@@ -6,20 +6,16 @@ from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import logging
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_board_or_404, require_admin_auth
|
||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
get_chat_history,
|
||||
send_message,
|
||||
)
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.models.agents import Agent
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
from app.models.boards import Board
|
||||
@@ -34,59 +30,8 @@ from app.schemas.boards import BoardRead
|
||||
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _extract_json(text: str) -> dict[str, object] | None:
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except Exception:
|
||||
pass
|
||||
first = text.find("{")
|
||||
last = text.rfind("}")
|
||||
if first != -1 and last > first:
|
||||
try:
|
||||
return json.loads(text[first : last + 1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(content: object) -> str | None:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
for entry in content:
|
||||
if isinstance(entry, dict) and entry.get("type") == "text":
|
||||
text = entry.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
if isinstance(content, dict):
|
||||
text = content.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _get_assistant_messages(history: object) -> list[str]:
|
||||
messages: list[str] = []
|
||||
if isinstance(history, dict):
|
||||
history = history.get("messages")
|
||||
if not isinstance(history, list):
|
||||
return messages
|
||||
for msg in history:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
text = _extract_text(msg.get("content"))
|
||||
if text:
|
||||
messages.append(text)
|
||||
return messages
|
||||
|
||||
|
||||
def _gateway_config(session: Session, board: Board) -> tuple[Gateway, GatewayClientConfig]:
|
||||
@@ -104,7 +49,11 @@ def _build_session_key(agent_name: str) -> str:
|
||||
|
||||
|
||||
def _lead_agent_name(board: Board) -> str:
|
||||
return f"{board.name} Lead"
|
||||
return "Lead Agent"
|
||||
|
||||
|
||||
def _lead_session_key(board: Board) -> str:
|
||||
return f"agent:lead-{board.id}:main"
|
||||
|
||||
|
||||
async def _ensure_lead_agent(
|
||||
@@ -120,6 +69,11 @@ async def _ensure_lead_agent(
|
||||
.where(Agent.is_board_lead.is_(True))
|
||||
).first()
|
||||
if existing:
|
||||
if existing.name != _lead_agent_name(board):
|
||||
existing.name = _lead_agent_name(board)
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
return existing
|
||||
|
||||
agent = Agent(
|
||||
@@ -131,14 +85,14 @@ async def _ensure_lead_agent(
|
||||
identity_profile={
|
||||
"role": "Board Lead",
|
||||
"communication_style": "direct, concise, practical",
|
||||
"emoji": ":compass:",
|
||||
"emoji": ":gear:",
|
||||
},
|
||||
)
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
agent.provision_requested_at = datetime.utcnow()
|
||||
agent.provision_action = "provision"
|
||||
agent.openclaw_session_id = _build_session_key(agent.name)
|
||||
agent.openclaw_session_id = _lead_session_key(board)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
@@ -200,17 +154,28 @@ async def start_onboarding(
|
||||
"BOARD ONBOARDING REQUEST\n\n"
|
||||
f"Board Name: {board.name}\n"
|
||||
"You are the main agent. Ask the user 3-6 focused questions to clarify their goal.\n"
|
||||
"Only respond in OpenClaw chat with onboarding JSON. All other outputs must be sent to Mission Control via API.\n"
|
||||
"Do NOT respond in OpenClaw chat.\n"
|
||||
"All onboarding responses MUST be sent to Mission Control via API.\n"
|
||||
f"Mission Control base URL: {base_url}\n"
|
||||
"Use the AUTH_TOKEN from MAIN_USER.md or MAIN_TOOLS.md and pass it as X-Agent-Token.\n"
|
||||
"Example API call (for non-onboarding updates):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/boards/{board.id}/memory\" "
|
||||
"Use the AUTH_TOKEN from USER.md or TOOLS.md and pass it as X-Agent-Token.\n"
|
||||
"Onboarding response endpoint:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/onboarding\n"
|
||||
"QUESTION example (send JSON body exactly as shown):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/agent/boards/{board.id}/onboarding\" "
|
||||
"-H \"X-Agent-Token: $AUTH_TOKEN\" "
|
||||
"-H \"Content-Type: application/json\" "
|
||||
"-d '{\"content\":\"Onboarding update...\",\"tags\":[\"onboarding\"],\"source\":\"main_agent\"}'\n"
|
||||
"Return questions as JSON: {\"question\": \"...\", \"options\": [...]}.\n"
|
||||
"When you have enough info, return JSON: {\"status\": \"complete\", \"board_type\": \"goal\"|\"general\", "
|
||||
"\"objective\": \"...\", \"success_metrics\": {...}, \"target_date\": \"YYYY-MM-DD\"}."
|
||||
"-d '{\"question\":\"...\",\"options\":[{\"id\":\"1\",\"label\":\"...\"},{\"id\":\"2\",\"label\":\"...\"}]}'\n"
|
||||
"COMPLETION example (send JSON body exactly as shown):\n"
|
||||
f"curl -s -X POST \"{base_url}/api/v1/agent/boards/{board.id}/onboarding\" "
|
||||
"-H \"X-Agent-Token: $AUTH_TOKEN\" "
|
||||
"-H \"Content-Type: application/json\" "
|
||||
"-d '{\"status\":\"complete\",\"board_type\":\"goal\",\"objective\":\"...\",\"success_metrics\":{...},\"target_date\":\"YYYY-MM-DD\"}'\n"
|
||||
"QUESTION FORMAT (one question per response, no arrays, no markdown, no extra text):\n"
|
||||
"{\"question\":\"...\",\"options\":[{\"id\":\"1\",\"label\":\"...\"},{\"id\":\"2\",\"label\":\"...\"}]}\n"
|
||||
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
|
||||
"When you have enough info, return JSON ONLY (via API):\n"
|
||||
"{\"status\":\"complete\",\"board_type\":\"goal\"|\"general\",\"objective\":\"...\","
|
||||
"\"success_metrics\":{...},\"target_date\":\"YYYY-MM-DD\"}."
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -251,7 +216,7 @@ async def answer_onboarding(
|
||||
if payload.other_text:
|
||||
answer_text = f"{payload.answer}: {payload.other_text}"
|
||||
|
||||
messages = onboarding.messages or []
|
||||
messages = list(onboarding.messages or [])
|
||||
messages.append(
|
||||
{"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
@@ -261,21 +226,9 @@ async def answer_onboarding(
|
||||
await send_message(
|
||||
answer_text, session_key=onboarding.session_key, config=config, deliver=False
|
||||
)
|
||||
history = await get_chat_history(onboarding.session_key, config=config)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
assistant_messages = _get_assistant_messages(history)
|
||||
if assistant_messages:
|
||||
last = assistant_messages[-1]
|
||||
messages.append(
|
||||
{"role": "assistant", "content": last, "timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
parsed = _extract_json(last)
|
||||
if parsed and parsed.get("status") == "complete":
|
||||
onboarding.draft_goal = parsed
|
||||
onboarding.status = "completed"
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = datetime.utcnow()
|
||||
session.add(onboarding)
|
||||
@@ -284,6 +237,70 @@ async def answer_onboarding(
|
||||
return onboarding
|
||||
|
||||
|
||||
@router.post("/agent", response_model=BoardOnboardingRead)
|
||||
def agent_onboarding_update(
|
||||
payload: dict[str, object],
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
) -> BoardOnboardingSession:
|
||||
if actor.actor_type != "agent" or actor.agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
agent = actor.agent
|
||||
if agent.board_id is not None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if board.gateway_id:
|
||||
gateway = session.get(Gateway, board.gateway_id)
|
||||
if gateway and gateway.main_session_key and agent.openclaw_session_id:
|
||||
if agent.openclaw_session_id != gateway.main_session_key:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
onboarding = session.exec(
|
||||
select(BoardOnboardingSession)
|
||||
.where(BoardOnboardingSession.board_id == board.id)
|
||||
.order_by(BoardOnboardingSession.created_at.desc())
|
||||
).first()
|
||||
if onboarding is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if onboarding.status == "confirmed":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
messages = list(onboarding.messages or [])
|
||||
now = datetime.utcnow().isoformat()
|
||||
payload_text = json.dumps(payload)
|
||||
logger.info(
|
||||
"onboarding.agent.update board_id=%s agent_id=%s payload=%s",
|
||||
board.id,
|
||||
agent.id,
|
||||
payload_text,
|
||||
)
|
||||
payload_status = payload.get("status")
|
||||
if payload_status == "complete":
|
||||
onboarding.draft_goal = payload
|
||||
onboarding.status = "completed"
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
else:
|
||||
question = payload.get("question")
|
||||
options = payload.get("options")
|
||||
if not isinstance(question, str) or not isinstance(options, list):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = datetime.utcnow()
|
||||
session.add(onboarding)
|
||||
session.commit()
|
||||
session.refresh(onboarding)
|
||||
logger.info(
|
||||
"onboarding.agent.update stored board_id=%s messages_count=%s status=%s",
|
||||
board.id,
|
||||
len(onboarding.messages or []),
|
||||
onboarding.status,
|
||||
)
|
||||
return onboarding
|
||||
|
||||
|
||||
@router.post("/confirm", response_model=BoardRead)
|
||||
async def confirm_onboarding(
|
||||
payload: BoardOnboardingConfirm,
|
||||
|
||||
@@ -311,7 +311,7 @@ async def _ensure_main_agent(
|
||||
await send_message(
|
||||
(
|
||||
f"Hello {agent.name}. Your gateway provisioning was updated.\n\n"
|
||||
"Please re-read MAIN_AGENTS.md, MAIN_USER.md, MAIN_HEARTBEAT.md, and MAIN_TOOLS.md. "
|
||||
"Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. "
|
||||
"If BOOTSTRAP.md exists, run it once then delete it. Begin heartbeats after startup."
|
||||
),
|
||||
session_key=gateway.main_session_key,
|
||||
|
||||
@@ -3,13 +3,16 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
import logging
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.core.agent_tokens import verify_agent_token
|
||||
from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentAuthContext:
|
||||
@@ -25,25 +28,78 @@ def _find_agent_for_token(session: Session, token: str) -> Agent | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_agent_token(
|
||||
agent_token: str | None,
|
||||
authorization: str | None,
|
||||
*,
|
||||
accept_authorization: bool = True,
|
||||
) -> str | None:
|
||||
if agent_token:
|
||||
return agent_token
|
||||
if not accept_authorization:
|
||||
return None
|
||||
if not authorization:
|
||||
return None
|
||||
value = authorization.strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.lower().startswith("bearer "):
|
||||
return value.split(" ", 1)[1].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def get_agent_auth_context(
|
||||
request: Request,
|
||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AgentAuthContext:
|
||||
if not agent_token:
|
||||
resolved = _resolve_agent_token(agent_token, authorization, accept_authorization=True)
|
||||
if not resolved:
|
||||
logger.warning(
|
||||
"agent auth missing token path=%s x_agent=%s authorization=%s",
|
||||
request.url.path,
|
||||
bool(agent_token),
|
||||
bool(authorization),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
agent = _find_agent_for_token(session, agent_token)
|
||||
agent = _find_agent_for_token(session, resolved)
|
||||
if agent is None:
|
||||
logger.warning(
|
||||
"agent auth invalid token path=%s token_prefix=%s",
|
||||
request.url.path,
|
||||
resolved[:6],
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
||||
|
||||
|
||||
def get_agent_auth_context_optional(
|
||||
request: Request,
|
||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AgentAuthContext | None:
|
||||
if not agent_token:
|
||||
resolved = _resolve_agent_token(
|
||||
agent_token,
|
||||
authorization,
|
||||
accept_authorization=False,
|
||||
)
|
||||
if not resolved:
|
||||
if agent_token:
|
||||
logger.warning(
|
||||
"agent auth optional missing token path=%s x_agent=%s authorization=%s",
|
||||
request.url.path,
|
||||
bool(agent_token),
|
||||
bool(authorization),
|
||||
)
|
||||
return None
|
||||
agent = _find_agent_for_token(session, agent_token)
|
||||
agent = _find_agent_for_token(session, resolved)
|
||||
if agent is None:
|
||||
logger.warning(
|
||||
"agent auth optional invalid token path=%s token_prefix=%s",
|
||||
request.url.path,
|
||||
resolved[:6],
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
||||
|
||||
@@ -112,17 +112,17 @@ async def get_auth_context_optional(
|
||||
clerk_credentials = await guard(request)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
|
||||
except HTTPException as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
auth_data = _resolve_clerk_auth(request, clerk_credentials)
|
||||
try:
|
||||
clerk_user_id = _parse_subject(auth_data)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
if not clerk_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return None
|
||||
|
||||
user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first()
|
||||
if user is None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.activity import router as activity_router
|
||||
from app.api.agent import router as agent_router
|
||||
from app.api.agents import router as agents_router
|
||||
from app.api.approvals import router as approvals_router
|
||||
from app.api.auth import router as auth_router
|
||||
@@ -56,6 +57,7 @@ def readyz() -> dict[str, bool]:
|
||||
|
||||
api_v1 = APIRouter(prefix="/api/v1")
|
||||
api_v1.include_router(auth_router)
|
||||
api_v1.include_router(agent_router)
|
||||
api_v1.include_router(agents_router)
|
||||
api_v1.include_router(activity_router)
|
||||
api_v1.include_router(gateway_router)
|
||||
|
||||
@@ -33,6 +33,7 @@ class AgentUpdate(SQLModel):
|
||||
|
||||
class AgentRead(AgentBase):
|
||||
id: UUID
|
||||
is_board_lead: bool = False
|
||||
openclaw_session_id: str | None = None
|
||||
last_seen_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user