From 0187ea420713c7946b8fd2fd8103617aab5108ba Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 15:42:07 +0530 Subject: [PATCH] feat: provision main agent on gateway --- backend/app/api/gateways.py | 95 ++++++++++++++ backend/app/services/agent_provisioning.py | 136 ++++++++++++++++++++- templates/HEARTBEAT.md | 5 + templates/HEARTBEAT_AGENT.md | 5 + templates/HEARTBEAT_LEAD.md | 5 + templates/MAIN_AGENTS.md | 32 +++++ templates/MAIN_BOOT.md | 7 ++ templates/MAIN_HEARTBEAT.md | 40 ++++++ templates/MAIN_USER.md | 19 +++ 9 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 templates/MAIN_AGENTS.md create mode 100644 templates/MAIN_BOOT.md create mode 100644 templates/MAIN_HEARTBEAT.md create mode 100644 templates/MAIN_USER.md diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index f1ea83e2..94d5dc2d 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -5,12 +5,17 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select +from datetime import datetime + +from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext, get_auth_context 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, send_message +from app.models.agents import Agent from app.models.gateways import Gateway from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate +from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_main_agent router = APIRouter(prefix="/gateways", tags=["gateways"]) @@ -227,6 +232,85 @@ rm -rf ~/.openclaw/skills/skyll """.strip() +def _main_agent_name(gateway: Gateway) -> str: + return f"{gateway.name} Main" + + +def _find_main_agent( + session: Session, + gateway: Gateway, + previous_name: str | None = None, + previous_session_key: str | None = None, +) -> Agent | None: + if gateway.main_session_key: + agent = session.exec( + select(Agent).where(Agent.openclaw_session_id == gateway.main_session_key) + ).first() + if agent: + return agent + if previous_session_key: + agent = session.exec( + select(Agent).where(Agent.openclaw_session_id == previous_session_key) + ).first() + if agent: + return agent + names = {_main_agent_name(gateway)} + if previous_name: + names.add(f"{previous_name} Main") + for name in names: + agent = session.exec(select(Agent).where(Agent.name == name)).first() + if agent: + return agent + return None + + +async def _ensure_main_agent( + session: Session, + gateway: Gateway, + auth: AuthContext, + *, + previous_name: str | None = None, + previous_session_key: str | None = None, + action: str = "provision", +) -> Agent | None: + if not gateway.url or not gateway.main_session_key: + return None + agent = _find_main_agent(session, gateway, previous_name, previous_session_key) + if agent is None: + agent = Agent( + name=_main_agent_name(gateway), + status="provisioning", + board_id=None, + is_board_lead=False, + openclaw_session_id=gateway.main_session_key, + heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), + identity_profile={ + "role": "Main Agent", + "communication_style": "direct, concise, practical", + "emoji": ":compass:", + }, + ) + session.add(agent) + agent.name = _main_agent_name(gateway) + agent.openclaw_session_id = gateway.main_session_key + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) + agent.provision_requested_at = datetime.utcnow() + agent.provision_action = action + agent.updated_at = datetime.utcnow() + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() + session.add(agent) + session.commit() + session.refresh(agent) + try: + await provision_main_agent(agent, gateway, raw_token, auth.user, action=action) + except OpenClawGatewayError: + # Best-effort provisioning. + pass + return agent + + async def _send_skyll_enable_message(gateway: Gateway) -> None: if not gateway.url: raise OpenClawGatewayError("Gateway url is required") @@ -278,6 +362,7 @@ async def create_gateway( session.add(gateway) session.commit() session.refresh(gateway) + await _ensure_main_agent(session, gateway, auth, action="provision") if gateway.skyll_enabled: try: await _send_skyll_enable_message(gateway) @@ -308,6 +393,8 @@ async def update_gateway( gateway = session.get(Gateway, gateway_id) if gateway is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") + previous_name = gateway.name + previous_session_key = gateway.main_session_key previous_skyll_enabled = gateway.skyll_enabled updates = payload.model_dump(exclude_unset=True) if updates.get("token") == "": @@ -317,6 +404,14 @@ async def update_gateway( session.add(gateway) session.commit() session.refresh(gateway) + await _ensure_main_agent( + session, + gateway, + auth, + previous_name=previous_name, + previous_session_key=previous_session_key, + action="update", + ) if not previous_skyll_enabled and gateway.skyll_enabled: try: await _send_skyll_enable_message(gateway) diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 216dd1bb..da5a02b7 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -37,6 +37,7 @@ DEFAULT_GATEWAY_FILES = frozenset( "IDENTITY.md", "USER.md", "HEARTBEAT.md", + "BOOT.md", "BOOTSTRAP.md", "MEMORY.md", } @@ -44,6 +45,12 @@ DEFAULT_GATEWAY_FILES = frozenset( HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md" HEARTBEAT_AGENT_TEMPLATE = "HEARTBEAT_AGENT.md" +MAIN_TEMPLATE_MAP = { + "AGENTS.md": "MAIN_AGENTS.md", + "HEARTBEAT.md": "MAIN_HEARTBEAT.md", + "USER.md": "MAIN_USER.md", + "BOOT.md": "MAIN_BOOT.md", +} def _repo_root() -> Path: @@ -159,6 +166,51 @@ def _build_context( } +def _build_main_context( + agent: Agent, + gateway: Gateway, + auth_token: str, + user: User | None, +) -> dict[str, str]: + base_url = settings.base_url or "REPLACE_WITH_BASE_URL" + identity_profile: dict[str, Any] = {} + if isinstance(agent.identity_profile, dict): + identity_profile = agent.identity_profile + normalized_identity: dict[str, str] = {} + for key, value in identity_profile.items(): + if value is None: + continue + if isinstance(value, list): + parts = [str(item).strip() for item in value if str(item).strip()] + if not parts: + continue + normalized_identity[key] = ", ".join(parts) + continue + text = str(value).strip() + if text: + normalized_identity[key] = text + identity_context = { + context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field]) + for field, context_key in IDENTITY_PROFILE_FIELDS.items() + } + return { + "agent_name": agent.name, + "agent_id": str(agent.id), + "session_key": agent.openclaw_session_id or "", + "base_url": base_url, + "auth_token": auth_token, + "main_session_key": gateway.main_session_key or "", + "workspace_root": gateway.workspace_root or "", + "user_name": (user.name or "") if user else "", + "user_preferred_name": (user.preferred_name or "") if user else "", + "user_pronouns": (user.pronouns or "") if user else "", + "user_timezone": (user.timezone or "") if user else "", + "user_notes": (user.notes or "") if user else "", + "user_context": (user.context or "") if user else "", + **identity_context, + } + + def _session_key(agent: Agent) -> str: if agent.openclaw_session_id: return agent.openclaw_session_id @@ -211,6 +263,7 @@ def _render_agent_files( file_names: set[str], *, include_bootstrap: bool, + template_overrides: dict[str, str] | None = None, ) -> dict[str, str]: env = _template_env() overrides: dict[str, str] = {} @@ -227,7 +280,11 @@ def _render_agent_files( rendered[name] = "# MEMORY\n\nBootstrap pending.\n" continue if name == "HEARTBEAT.md": - heartbeat_template = _heartbeat_template_name(agent) + heartbeat_template = ( + template_overrides.get(name) + if template_overrides and name in template_overrides + else _heartbeat_template_name(agent) + ) heartbeat_path = _templates_root() / heartbeat_template if heartbeat_path.exists(): rendered[name] = ( @@ -238,14 +295,39 @@ def _render_agent_files( if override: rendered[name] = env.from_string(override).render(**context).strip() continue - path = _templates_root() / name + template_name = ( + template_overrides.get(name) + if template_overrides and name in template_overrides + else name + ) + path = _templates_root() / template_name if path.exists(): - rendered[name] = env.get_template(name).render(**context).strip() + rendered[name] = env.get_template(template_name).render(**context).strip() continue rendered[name] = "" return rendered +async def _gateway_default_agent_id( + config: GatewayClientConfig, +) -> str | None: + try: + payload = await openclaw_call("agents.list", config=config) + except OpenClawGatewayError: + return None + if not isinstance(payload, dict): + return None + default_id = payload.get("defaultId") or payload.get("default_id") + if default_id: + return default_id + agents = payload.get("agents") or [] + if isinstance(agents, list) and agents: + first = agents[0] + if isinstance(first, dict): + return first.get("id") + return None + + async def _patch_gateway_agent_list( agent_id: str, workspace_path: str, @@ -381,6 +463,54 @@ async def provision_agent( ) +async def provision_main_agent( + agent: Agent, + gateway: Gateway, + auth_token: str, + user: User | None, + *, + action: str = "provision", +) -> None: + if not gateway.url: + return + if not gateway.main_session_key: + raise ValueError("gateway main_session_key is required") + client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) + await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent") + + agent_id = await _gateway_default_agent_id(client_config) + if not agent_id: + raise OpenClawGatewayError("Unable to resolve gateway main agent id") + + 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": + if not existing_files: + include_bootstrap = False + else: + entry = existing_files.get("BOOTSTRAP.md") + if entry and entry.get("missing") is True: + include_bootstrap = False + + rendered = _render_agent_files( + context, + agent, + supported, + include_bootstrap=include_bootstrap, + template_overrides=MAIN_TEMPLATE_MAP, + ) + for name, content in rendered.items(): + if content == "": + continue + await openclaw_call( + "agents.files.set", + {"agentId": agent_id, "name": name, "content": content}, + config=client_config, + ) + + async def cleanup_agent( agent: Agent, gateway: Gateway, diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index 9e7edfa9..cd7e9a4b 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -24,6 +24,11 @@ If any required input is missing, stop and request a provisioning update. - Every status change must have a comment within 30 seconds. - Do not claim a new task if you already have one in progress. +## Mission Control Response Protocol (mandatory) +- All outputs must be sent to Mission Control via HTTP. +- Always include: `X-Agent-Token: $AUTH_TOKEN` +- Do **not** respond in OpenClaw chat. + ## Pre‑flight checks (before each heartbeat) - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Verify API access: diff --git a/templates/HEARTBEAT_AGENT.md b/templates/HEARTBEAT_AGENT.md index 19a7a987..d8e4e4fd 100644 --- a/templates/HEARTBEAT_AGENT.md +++ b/templates/HEARTBEAT_AGENT.md @@ -22,6 +22,11 @@ If any required input is missing, stop and request a provisioning update. - Every status change must have a comment within 30 seconds. - Do not claim a new task if you already have one in progress. +## Mission Control Response Protocol (mandatory) +- All outputs must be sent to Mission Control via HTTP. +- Always include: `X-Agent-Token: $AUTH_TOKEN` +- Do **not** respond in OpenClaw chat. + ## Pre‑flight checks (before each heartbeat) - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Verify API access: diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index 3727c43f..509c6493 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -22,6 +22,11 @@ If any required input is missing, stop and request a provisioning update. - Every status change must have a comment within 30 seconds. - Do not claim a new task if you already have one in progress. +## Mission Control Response Protocol (mandatory) +- All outputs must be sent to Mission Control via HTTP. +- Always include: `X-Agent-Token: $AUTH_TOKEN` +- Do **not** respond in OpenClaw chat. + ## Pre‑flight checks (before each heartbeat) - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Verify API access: diff --git a/templates/MAIN_AGENTS.md b/templates/MAIN_AGENTS.md new file mode 100644 index 00000000..d97ce59c --- /dev/null +++ b/templates/MAIN_AGENTS.md @@ -0,0 +1,32 @@ +# MAIN_AGENTS.md + +This workspace belongs to the **Main Agent** for this gateway. You are not tied to a single board. + +## First run +- If BOOTSTRAP.md exists, follow it once and delete it when finished. + +## Every session +Before doing anything else: +1) Read SOUL.md (identity, boundaries) +2) Read MAIN_USER.md (who you serve) +3) Read memory/YYYY-MM-DD.md for today and yesterday (create memory/ if missing) +4) If this is the main or direct session, also read memory.md + +## Mission Control API (required) +- All work outputs must be sent to Mission Control via HTTP using: + - `BASE_URL`: {{ base_url }} + - `AUTH_TOKEN`: {{ auth_token }} +- Always include header: `X-Agent-Token: $AUTH_TOKEN` +- Do **not** post task updates in OpenClaw chat (onboarding JSON is the only exception). + +## Scope +- You help with onboarding and gateway-wide requests. +- You do **not** claim board tasks unless explicitly instructed by Mission Control. + +## Tools +- Skills are authoritative. Follow SKILL.md instructions exactly. +- Use TOOLS.md for environment-specific notes. + +## Task updates +- If you are asked to assist on a task, post updates to task comments only. +- Comments must be markdown. diff --git a/templates/MAIN_BOOT.md b/templates/MAIN_BOOT.md new file mode 100644 index 00000000..0c7dba0e --- /dev/null +++ b/templates/MAIN_BOOT.md @@ -0,0 +1,7 @@ +# MAIN_BOOT.md + +You are the **Main Agent** for this gateway. + +- Read MAIN_AGENTS.md and MAIN_USER.md first. +- Use Mission Control API for all outputs. +- Do not respond in OpenClaw chat. diff --git a/templates/MAIN_HEARTBEAT.md b/templates/MAIN_HEARTBEAT.md new file mode 100644 index 00000000..9caf7879 --- /dev/null +++ b/templates/MAIN_HEARTBEAT.md @@ -0,0 +1,40 @@ +# MAIN_HEARTBEAT.md + +## Purpose +This file defines the main agent heartbeat. You are not tied to any board. + +## Required inputs +- BASE_URL (e.g. http://localhost:8000) +- AUTH_TOKEN (agent token) +- AGENT_NAME +- AGENT_ID + +If any required input is missing, stop and request a provisioning update. + +## Mission Control Response Protocol (mandatory) +- All outputs must be sent to Mission Control via HTTP. +- Always include: `X-Agent-Token: $AUTH_TOKEN` +- Do **not** respond in OpenClaw chat **except** for onboarding JSON when explicitly prompted. + +## 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. + +## Heartbeat checklist +1) Check in: +```bash +curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \ + -H "X-Agent-Token: $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "'$AGENT_NAME'", "status": "online"}' +``` + +## Onboarding protocol +- When Mission Control asks you to onboard a board, respond in OpenClaw chat with JSON only: + - Question format: {"question": "...", "options": [{"id":"1","label":"..."}]} + - Completion format: {"status":"complete","board_type":"goal"|"general","objective":"...","success_metrics":{...},"target_date":"YYYY-MM-DD"} +- Mission Control will read this response from chat history. + +## Common mistakes (avoid) +- Posting updates in OpenClaw chat. +- Claiming board tasks without instruction. diff --git a/templates/MAIN_USER.md b/templates/MAIN_USER.md new file mode 100644 index 00000000..3e2acd79 --- /dev/null +++ b/templates/MAIN_USER.md @@ -0,0 +1,19 @@ +# MAIN_USER.md + +## User +- Name: {{ user_name }} +- Preferred name: {{ user_preferred_name }} +- Pronouns: {{ user_pronouns }} +- Timezone: {{ user_timezone }} + +## Context +{{ user_context }} + +## Notes +{{ user_notes }} + +## Mission Control +- Base URL: {{ base_url }} +- Auth token: {{ auth_token }} + +You are the **Main Agent** for this gateway. You are not tied to a specific board.