feat(gateway): add endpoint for user outreach via gateway main agent

This commit is contained in:
Abhimanyu Saharan
2026-02-07 16:21:31 +05:30
parent 0816fb6cd3
commit 13b3701810
6 changed files with 134 additions and 5 deletions

View File

@@ -50,6 +50,8 @@ from app.schemas.gateway_coordination import (
GatewayLeadBroadcastResponse, GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest, GatewayLeadMessageRequest,
GatewayLeadMessageResponse, GatewayLeadMessageResponse,
GatewayMainAskUserRequest,
GatewayMainAskUserResponse,
) )
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate 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":"<answer>","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) @router.post("/gateway/boards/ensure", response_model=GatewayBoardEnsureResponse)
async def ensure_gateway_board( async def ensure_gateway_board(
payload: GatewayBoardEnsureRequest, payload: GatewayBoardEnsureRequest,

View File

@@ -73,3 +73,19 @@ class GatewayLeadBroadcastResponse(SQLModel):
failed: int = 0 failed: int = 0
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list) 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

View File

@@ -1,13 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import sys
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from app.db.session import async_session_maker, init_db BACKEND_ROOT = Path(__file__).resolve().parents[1]
from app.models.agents import Agent sys.path.insert(0, str(BACKEND_ROOT))
from app.models.boards import Board
from app.models.gateways import Gateway from app.db.session import async_session_maker, init_db # noqa: E402
from app.models.users import User 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: async def run() -> None:

View File

@@ -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. - 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. - 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":"<question>","correlation_id":"<optional>","preferred_channel":"<optional>"}`
- 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 ## Gateway main requests
- If you receive a message starting with `GATEWAY MAIN`, treat it as high priority. - 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. - Do **not** reply in OpenClaw chat. Reply via Mission Control only.

View File

@@ -62,6 +62,11 @@ Board lead replies:
- Read replies via: - Read replies via:
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50` - GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/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 ## Tools
- Skills are authoritative. Follow SKILL.md instructions exactly. - Skills are authoritative. Follow SKILL.md instructions exactly.
- Use TOOLS.md for environment-specific notes. - Use TOOLS.md for environment-specific notes.

View File

@@ -19,6 +19,7 @@ If any required input is missing, stop and request a provisioning update.
## Schedule ## Schedule
- If a heartbeat schedule is configured, send a lightweight checkin only. - If a heartbeat schedule is configured, send a lightweight checkin only.
- Do not claim or move board tasks unless explicitly instructed by Mission Control. - 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 ## Heartbeat checklist
1) Check in: 1) Check in: