diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index b7f9474b..98a7bc19 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -50,6 +50,8 @@ from app.schemas.gateway_coordination import ( GatewayLeadBroadcastResponse, GatewayLeadMessageRequest, GatewayLeadMessageResponse, + GatewayMainAskUserRequest, + GatewayMainAskUserResponse, ) from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate @@ -498,6 +500,100 @@ async def agent_heartbeat( ) +@router.post( + "/boards/{board_id}/gateway/main/ask-user", + response_model=GatewayMainAskUserResponse, +) +async def ask_user_via_gateway_main( + payload: GatewayMainAskUserRequest, + board: Board = Depends(get_board_or_404), + session: AsyncSession = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> GatewayMainAskUserResponse: + import json + + _guard_board_access(agent_ctx, board) + if not agent_ctx.agent.is_board_lead: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + if not board.gateway_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board is not attached to a gateway", + ) + gateway = await session.get(Gateway, board.gateway_id) + if gateway is None or not gateway.url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway is not configured for this board", + ) + main_session_key = (gateway.main_session_key or "").strip() + if not main_session_key: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway main session key is required", + ) + config = GatewayClientConfig(url=gateway.url, token=gateway.token) + + correlation = payload.correlation_id.strip() if payload.correlation_id else "" + correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" + preferred_channel = (payload.preferred_channel or "").strip() + channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else "" + + tags = payload.reply_tags or ["gateway_main", "user_reply"] + tags_json = json.dumps(tags) + reply_source = payload.reply_source or "user_via_gateway_main" + base_url = settings.base_url or "http://localhost:8000" + + message = ( + "LEAD REQUEST: ASK USER\n" + f"Board: {board.name}\n" + f"Board ID: {board.id}\n" + f"From lead: {agent_ctx.agent.name}\n" + f"{correlation_line}" + f"{channel_line}\n" + f"{payload.content.strip()}\n\n" + "Please reach the user via your configured OpenClaw channel(s) (Slack/SMS/etc).\n" + "If you cannot reach them there, post the question in Mission Control board chat as a fallback.\n\n" + "When you receive the answer, reply in Mission Control by writing a NON-chat memory item on this board:\n" + f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" + f'Body: {{"content":"","tags":{tags_json},"source":"{reply_source}"}}\n' + "Do NOT reply in OpenClaw chat." + ) + + try: + await ensure_session(main_session_key, config=config, label="Main Agent") + await send_message(message, session_key=main_session_key, config=config, deliver=True) + except OpenClawGatewayError as exc: + record_activity( + session, + event_type="gateway.lead.ask_user.failed", + message=f"Lead user question failed for {board.name}: {exc}", + agent_id=agent_ctx.agent.id, + ) + await session.commit() + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + record_activity( + session, + event_type="gateway.lead.ask_user.sent", + message=f"Lead requested user info via gateway main for board: {board.name}.", + agent_id=agent_ctx.agent.id, + ) + + main_agent = ( + await session.exec(select(Agent).where(col(Agent.openclaw_session_id) == main_session_key)) + ).first() + + await session.commit() + + return GatewayMainAskUserResponse( + board_id=board.id, + main_agent_id=main_agent.id if main_agent else None, + main_agent_name=main_agent.name if main_agent else None, + ) + + @router.post("/gateway/boards/ensure", response_model=GatewayBoardEnsureResponse) async def ensure_gateway_board( payload: GatewayBoardEnsureRequest, diff --git a/backend/app/schemas/gateway_coordination.py b/backend/app/schemas/gateway_coordination.py index 55a5b1e4..e0993d8e 100644 --- a/backend/app/schemas/gateway_coordination.py +++ b/backend/app/schemas/gateway_coordination.py @@ -73,3 +73,19 @@ class GatewayLeadBroadcastResponse(SQLModel): failed: int = 0 results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list) + +class GatewayMainAskUserRequest(SQLModel): + correlation_id: str | None = None + content: NonEmptyStr + preferred_channel: str | None = None + + # How the main agent should reply back into Mission Control (defaults interpreted by templates). + reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "user_reply"]) + reply_source: str | None = "user_via_gateway_main" + + +class GatewayMainAskUserResponse(SQLModel): + ok: bool = True + board_id: UUID + main_agent_id: UUID | None = None + main_agent_name: str | None = None diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index af5f1b4f..324ee849 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -1,13 +1,18 @@ from __future__ import annotations import asyncio +import sys +from pathlib import Path from uuid import uuid4 -from app.db.session import async_session_maker, init_db -from app.models.agents import Agent -from app.models.boards import Board -from app.models.gateways import Gateway -from app.models.users import User +BACKEND_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND_ROOT)) + +from app.db.session import async_session_maker, init_db # noqa: E402 +from app.models.agents import Agent # noqa: E402 +from app.models.boards import Board # noqa: E402 +from app.models.gateways import Gateway # noqa: E402 +from app.models.users import User # noqa: E402 async def run() -> None: diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index 5f2484f6..742fbad1 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -66,6 +66,12 @@ Comment template (keep it small; 1-3 bullets per section; omit what is not appli - Board chat is your primary channel with the human; respond promptly and clearly. - If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them. +## Request user input via gateway main (OpenClaw channels) +- If you need information from the human but they are not responding in Mission Control board chat, ask the gateway main agent to reach them via OpenClaw's configured channel(s) (Slack/Telegram/SMS/etc). +- POST `$BASE_URL/api/v1/agent/boards/$BOARD_ID/gateway/main/ask-user` + - Body: `{"content":"","correlation_id":"","preferred_channel":""}` +- The gateway main will post the user's answer back to this board as a NON-chat memory item tagged like `["gateway_main","user_reply"]`. + ## Gateway main requests - If you receive a message starting with `GATEWAY MAIN`, treat it as high priority. - Do **not** reply in OpenClaw chat. Reply via Mission Control only. diff --git a/templates/MAIN_AGENTS.md b/templates/MAIN_AGENTS.md index 5d8c09ca..a3413089 100644 --- a/templates/MAIN_AGENTS.md +++ b/templates/MAIN_AGENTS.md @@ -62,6 +62,11 @@ Board lead replies: - Read replies via: - GET `$BASE_URL/api/v1/agent/boards//memory?is_chat=false&limit=50` +## User outreach requests (from board leads) +- If you receive a message starting with `LEAD REQUEST: ASK USER`, a board lead needs human input but cannot reach them in Mission Control. +- Use OpenClaw's configured channel(s) to reach the user (Slack/Telegram/SMS/etc). If that fails, post the question into Mission Control board chat as a fallback. +- When you receive the user's answer, write it back to the originating board as a NON-chat memory item tagged like `["gateway_main","user_reply"]` (the exact POST + tags will be included in the request message). + ## Tools - Skills are authoritative. Follow SKILL.md instructions exactly. - Use TOOLS.md for environment-specific notes. diff --git a/templates/MAIN_HEARTBEAT.md b/templates/MAIN_HEARTBEAT.md index bf0f73e5..e5b6c087 100644 --- a/templates/MAIN_HEARTBEAT.md +++ b/templates/MAIN_HEARTBEAT.md @@ -19,6 +19,7 @@ If any required input is missing, stop and request a provisioning update. ## Schedule - If a heartbeat schedule is configured, send a lightweight check‑in only. - Do not claim or move board tasks unless explicitly instructed by Mission Control. +- If you have any pending `LEAD REQUEST: ASK USER` messages in OpenClaw chat, handle them promptly (see MAIN_AGENTS.md). ## Heartbeat checklist 1) Check in: