feat: provision main agent on gateway
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
32
templates/MAIN_AGENTS.md
Normal file
32
templates/MAIN_AGENTS.md
Normal 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
7
templates/MAIN_BOOT.md
Normal 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.
|
||||
40
templates/MAIN_HEARTBEAT.md
Normal file
40
templates/MAIN_HEARTBEAT.md
Normal 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 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.
|
||||
19
templates/MAIN_USER.md
Normal file
19
templates/MAIN_USER.md
Normal 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.
|
||||
Reference in New Issue
Block a user