feat: provision main agent on gateway

This commit is contained in:
Abhimanyu Saharan
2026-02-05 15:42:07 +05:30
parent 3f3e44eff8
commit 0187ea4207
9 changed files with 341 additions and 3 deletions

View File

@@ -5,12 +5,17 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select 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.core.auth import AuthContext, get_auth_context
from app.db.session import get_session from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message 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.models.gateways import Gateway
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate 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"]) router = APIRouter(prefix="/gateways", tags=["gateways"])
@@ -227,6 +232,85 @@ rm -rf ~/.openclaw/skills/skyll
""".strip() """.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: async def _send_skyll_enable_message(gateway: Gateway) -> None:
if not gateway.url: if not gateway.url:
raise OpenClawGatewayError("Gateway url is required") raise OpenClawGatewayError("Gateway url is required")
@@ -278,6 +362,7 @@ async def create_gateway(
session.add(gateway) session.add(gateway)
session.commit() session.commit()
session.refresh(gateway) session.refresh(gateway)
await _ensure_main_agent(session, gateway, auth, action="provision")
if gateway.skyll_enabled: if gateway.skyll_enabled:
try: try:
await _send_skyll_enable_message(gateway) await _send_skyll_enable_message(gateway)
@@ -308,6 +393,8 @@ async def update_gateway(
gateway = session.get(Gateway, gateway_id) gateway = session.get(Gateway, gateway_id)
if gateway is None: if gateway is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found") 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 previous_skyll_enabled = gateway.skyll_enabled
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if updates.get("token") == "": if updates.get("token") == "":
@@ -317,6 +404,14 @@ async def update_gateway(
session.add(gateway) session.add(gateway)
session.commit() session.commit()
session.refresh(gateway) 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: if not previous_skyll_enabled and gateway.skyll_enabled:
try: try:
await _send_skyll_enable_message(gateway) await _send_skyll_enable_message(gateway)

View File

@@ -37,6 +37,7 @@ DEFAULT_GATEWAY_FILES = frozenset(
"IDENTITY.md", "IDENTITY.md",
"USER.md", "USER.md",
"HEARTBEAT.md", "HEARTBEAT.md",
"BOOT.md",
"BOOTSTRAP.md", "BOOTSTRAP.md",
"MEMORY.md", "MEMORY.md",
} }
@@ -44,6 +45,12 @@ DEFAULT_GATEWAY_FILES = frozenset(
HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md" HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md"
HEARTBEAT_AGENT_TEMPLATE = "HEARTBEAT_AGENT.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: 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: def _session_key(agent: Agent) -> str:
if agent.openclaw_session_id: if agent.openclaw_session_id:
return agent.openclaw_session_id return agent.openclaw_session_id
@@ -211,6 +263,7 @@ def _render_agent_files(
file_names: set[str], file_names: set[str],
*, *,
include_bootstrap: bool, include_bootstrap: bool,
template_overrides: dict[str, str] | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
env = _template_env() env = _template_env()
overrides: dict[str, str] = {} overrides: dict[str, str] = {}
@@ -227,7 +280,11 @@ def _render_agent_files(
rendered[name] = "# MEMORY\n\nBootstrap pending.\n" rendered[name] = "# MEMORY\n\nBootstrap pending.\n"
continue continue
if name == "HEARTBEAT.md": 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 heartbeat_path = _templates_root() / heartbeat_template
if heartbeat_path.exists(): if heartbeat_path.exists():
rendered[name] = ( rendered[name] = (
@@ -238,14 +295,39 @@ def _render_agent_files(
if override: if override:
rendered[name] = env.from_string(override).render(**context).strip() rendered[name] = env.from_string(override).render(**context).strip()
continue 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(): if path.exists():
rendered[name] = env.get_template(name).render(**context).strip() rendered[name] = env.get_template(template_name).render(**context).strip()
continue continue
rendered[name] = "" rendered[name] = ""
return rendered 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( async def _patch_gateway_agent_list(
agent_id: str, agent_id: str,
workspace_path: 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( async def cleanup_agent(
agent: Agent, agent: Agent,
gateway: Gateway, gateway: Gateway,

View File

@@ -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. - Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress. - 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.
## Preflight checks (before each heartbeat) ## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access: - Verify API access:

View File

@@ -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. - Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress. - 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.
## Preflight checks (before each heartbeat) ## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access: - Verify API access:

View File

@@ -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. - Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress. - 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.
## Preflight checks (before each heartbeat) ## Preflight checks (before each heartbeat)
- Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set. - Confirm BASE_URL, AUTH_TOKEN, and BOARD_ID are set.
- Verify API access: - Verify API access:

32
templates/MAIN_AGENTS.md Normal file
View File

@@ -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.

7
templates/MAIN_BOOT.md Normal file
View File

@@ -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.

View File

@@ -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 checkin 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.

19
templates/MAIN_USER.md Normal file
View File

@@ -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.