Merge branch 'master' into docs/backend-doc-pass

This commit is contained in:
Abhimanyu Saharan
2026-02-25 03:32:14 +05:30
committed by GitHub
344 changed files with 36956 additions and 3626 deletions

View File

@@ -8,6 +8,7 @@ DB-backed workflows (template sync, lead-agent record creation) live in
from __future__ import annotations
import json
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
@@ -19,7 +20,9 @@ from app.core.config import settings
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services import souls_directory
from app.services.openclaw.constants import (
BOARD_SHARED_TEMPLATE_MAP,
DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY,
DEFAULT_GATEWAY_FILES,
DEFAULT_HEARTBEAT_CONFIG,
@@ -28,6 +31,8 @@ from app.services.openclaw.constants import (
HEARTBEAT_AGENT_TEMPLATE,
HEARTBEAT_LEAD_TEMPLATE,
IDENTITY_PROFILE_FIELDS,
LEAD_GATEWAY_FILES,
LEAD_TEMPLATE_MAP,
MAIN_TEMPLATE_MAP,
PRESERVE_AGENT_EDITABLE_FILES,
)
@@ -56,6 +61,11 @@ class ProvisionOptions:
action: str = "provision"
force_bootstrap: bool = False
overwrite: bool = False
_ROLE_SOUL_MAX_CHARS = 24_000
_ROLE_SOUL_WORD_RE = re.compile(r"[a-z0-9]+")
def _is_missing_session_error(exc: OpenClawGatewayError) -> bool:
@@ -73,6 +83,17 @@ def _is_missing_session_error(exc: OpenClawGatewayError) -> bool:
)
def _is_missing_agent_error(exc: OpenClawGatewayError) -> bool:
message = str(exc).lower()
if not message:
return False
if any(
marker in message for marker in ("unknown agent", "no such agent", "agent does not exist")
):
return True
return "agent" in message and "not found" in message
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
@@ -175,16 +196,41 @@ def _workspace_path(agent: Agent, workspace_root: str) -> str:
return f"{root}/workspace-{slugify(key)}"
def _email_local_part(email: str) -> str:
normalized = email.strip()
if not normalized:
return ""
local, _sep, _domain = normalized.partition("@")
return local.strip() or normalized
def _display_name(user: User | None) -> str:
if user is None:
return ""
name = (user.name or "").strip()
if name:
return name
return (user.email or "").strip()
def _preferred_name(user: User | None) -> str:
preferred_name = (user.preferred_name or "") if user else ""
if preferred_name:
preferred_name = preferred_name.strip().split()[0]
return preferred_name
if preferred_name:
return preferred_name
display_name = _display_name(user)
if display_name:
if "@" in display_name:
return _email_local_part(display_name)
return display_name.split()[0]
email = (user.email or "") if user else ""
return _email_local_part(email)
def _user_context(user: User | None) -> dict[str, str]:
return {
"user_name": (user.name or "") if user else "",
"user_name": _display_name(user),
"user_preferred_name": _preferred_name(user),
"user_pronouns": (user.pronouns or "") if user else "",
"user_timezone": (user.timezone or "") if user else "",
@@ -226,6 +272,72 @@ def _identity_context(agent: Agent) -> dict[str, str]:
return {**identity_context, **extra_identity_context}
def _role_slug(role: str) -> str:
tokens = _ROLE_SOUL_WORD_RE.findall(role.strip().lower())
return "-".join(tokens)
def _select_role_soul_ref(
refs: list[souls_directory.SoulRef],
*,
role: str,
) -> souls_directory.SoulRef | None:
role_slug = _role_slug(role)
if not role_slug:
return None
exact_slug = next((ref for ref in refs if ref.slug.lower() == role_slug), None)
if exact_slug is not None:
return exact_slug
prefix_matches = [ref for ref in refs if ref.slug.lower().startswith(f"{role_slug}-")]
if prefix_matches:
return sorted(prefix_matches, key=lambda ref: len(ref.slug))[0]
contains_matches = [ref for ref in refs if role_slug in ref.slug.lower()]
if contains_matches:
return sorted(contains_matches, key=lambda ref: len(ref.slug))[0]
role_tokens = [token for token in role_slug.split("-") if token]
if len(role_tokens) < 2:
return None
scored: list[tuple[int, souls_directory.SoulRef]] = []
for ref in refs:
haystack = f"{ref.handle}-{ref.slug}".lower()
token_hits = sum(1 for token in role_tokens if token in haystack)
if token_hits >= 2:
scored.append((token_hits, ref))
if not scored:
return None
scored.sort(key=lambda item: (-item[0], len(item[1].slug)))
return scored[0][1]
async def _resolve_role_soul_markdown(role: str) -> tuple[str, str]:
if not role.strip():
return "", ""
try:
refs = await souls_directory.list_souls_directory_refs()
matched_ref = _select_role_soul_ref(refs, role=role)
if matched_ref is None:
return "", ""
content = await souls_directory.fetch_soul_markdown(
handle=matched_ref.handle,
slug=matched_ref.slug,
)
normalized = content.strip()
if not normalized:
return "", ""
if len(normalized) > _ROLE_SOUL_MAX_CHARS:
normalized = normalized[:_ROLE_SOUL_MAX_CHARS]
return normalized, matched_ref.page_url
except Exception:
# Best effort only. Provisioning must remain robust even if directory is unavailable.
return "", ""
def _build_context(
agent: Agent,
board: Board,
@@ -254,7 +366,15 @@ def _build_context(
"board_success_metrics": json.dumps(board.success_metrics or {}),
"board_target_date": board.target_date.isoformat() if board.target_date else "",
"board_goal_confirmed": str(board.goal_confirmed).lower(),
"board_rule_require_approval_for_done": str(board.require_approval_for_done).lower(),
"board_rule_require_review_before_done": str(board.require_review_before_done).lower(),
"board_rule_block_status_changes_with_pending_approval": str(
board.block_status_changes_with_pending_approval
).lower(),
"board_rule_only_lead_can_change_status": str(board.only_lead_can_change_status).lower(),
"board_rule_max_agents": str(board.max_agents),
"is_board_lead": str(agent.is_board_lead).lower(),
"is_main_agent": "false",
"session_key": session_key,
"workspace_path": workspace_path,
"base_url": base_url,
@@ -278,6 +398,7 @@ def _build_main_context(
return {
"agent_name": agent.name,
"agent_id": str(agent.id),
"is_main_agent": "true",
"session_key": agent.openclaw_session_id or "",
"base_url": base_url,
"auth_token": auth_token,
@@ -337,6 +458,9 @@ def _render_agent_files(
template_name = (
template_overrides[name] if template_overrides and name in template_overrides else name
)
if template_name == "SOUL.md":
# Use shared Jinja soul template as the default implementation.
template_name = "BOARD_SOUL.md.j2"
path = _templates_root() / template_name
if not path.exists():
msg = f"Missing template file: {template_name}"
@@ -394,6 +518,10 @@ class GatewayControlPlane(ABC):
async def set_agent_file(self, *, agent_id: str, name: str, content: str) -> None:
raise NotImplementedError
@abstractmethod
async def delete_agent_file(self, *, agent_id: str, name: str) -> None:
raise NotImplementedError
@abstractmethod
async def patch_agent_heartbeats(
self,
@@ -501,6 +629,13 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
config=self._config,
)
async def delete_agent_file(self, *, agent_id: str, name: str) -> None:
await openclaw_call(
"agents.files.delete",
{"agentId": agent_id, "name": name},
config=self._config,
)
async def patch_agent_heartbeats(
self,
entries: list[tuple[str, str, dict[str, Any]]],
@@ -603,36 +738,62 @@ class BaseAgentLifecycleManager(ABC):
) -> dict[str, str]:
raise NotImplementedError
def _template_overrides(self) -> dict[str, str] | None:
async def _augment_context(
self,
*,
agent: Agent,
context: dict[str, str],
) -> dict[str, str]:
_ = agent
return context
def _template_overrides(self, agent: Agent) -> dict[str, str] | None:
return None
def _preserve_files(self) -> set[str]:
def _file_names(self, agent: Agent) -> set[str]:
_ = agent
return set(DEFAULT_GATEWAY_FILES)
def _preserve_files(self, agent: Agent) -> set[str]:
_ = agent
"""Files that are expected to evolve inside the agent workspace."""
return set(PRESERVE_AGENT_EDITABLE_FILES)
def _allow_stale_file_deletion(self, agent: Agent) -> bool:
_ = agent
return False
def _stale_file_candidates(self, agent: Agent) -> set[str]:
_ = agent
return set()
async def _set_agent_files(
self,
*,
agent: Agent | None = None,
agent_id: str,
rendered: dict[str, str],
desired_file_names: set[str] | None = None,
existing_files: dict[str, dict[str, Any]],
action: str,
overwrite: bool = False,
) -> None:
preserve_files = (
self._preserve_files(agent) if agent is not None else set(PRESERVE_AGENT_EDITABLE_FILES)
)
target_file_names = desired_file_names or set(rendered.keys())
unsupported_names: list[str] = []
for name, content in rendered.items():
if content == "":
continue
# Preserve "editable" files only during updates. During first-time provisioning,
# the gateway may pre-create defaults for USER/SELF/etc, and we still want to
# the gateway may pre-create defaults for USER/MEMORY/etc, and we still want to
# apply Mission Control's templates.
if action == "update" and name in self._preserve_files():
if action == "update" and not overwrite and name in preserve_files:
entry = existing_files.get(name)
if entry and not bool(entry.get("missing")):
size = entry.get("size")
if isinstance(size, int) and size == 0:
# Treat 0-byte placeholders as missing so update can fill them.
pass
else:
continue
continue
try:
await self._control_plane.set_agent_file(
agent_id=agent_id,
@@ -641,6 +802,38 @@ class BaseAgentLifecycleManager(ABC):
)
except OpenClawGatewayError as exc:
if "unsupported file" in str(exc).lower():
unsupported_names.append(name)
continue
raise
if agent is not None and agent.is_board_lead and unsupported_names:
unsupported_sorted = ", ".join(sorted(set(unsupported_names)))
msg = (
"Gateway rejected required lead workspace files as unsupported: "
f"{unsupported_sorted}"
)
raise RuntimeError(msg)
if agent is None or not self._allow_stale_file_deletion(agent):
return
stale_names = (
set(existing_files.keys()) & self._stale_file_candidates(agent)
) - target_file_names
for name in sorted(stale_names):
try:
await self._control_plane.delete_agent_file(agent_id=agent_id, name=name)
except OpenClawGatewayError as exc:
message = str(exc).lower()
if any(
marker in message
for marker in (
"unsupported",
"unknown method",
"not found",
"no such file",
)
):
continue
raise
@@ -679,9 +872,10 @@ class BaseAgentLifecycleManager(ABC):
user=user,
board=board,
)
context = await self._augment_context(agent=agent, context=context)
# Always attempt to sync Mission Control's full template set.
# Do not introspect gateway defaults (avoids touching gateway "main" agent state).
file_names = set(DEFAULT_GATEWAY_FILES)
file_names = self._file_names(agent)
existing_files = await self._control_plane.list_agent_files(agent_id)
include_bootstrap = _should_include_bootstrap(
action=options.action,
@@ -693,14 +887,17 @@ class BaseAgentLifecycleManager(ABC):
agent,
file_names,
include_bootstrap=include_bootstrap,
template_overrides=self._template_overrides(),
template_overrides=self._template_overrides(agent),
)
await self._set_agent_files(
agent=agent,
agent_id=agent_id,
rendered=rendered,
desired_file_names=set(rendered.keys()),
existing_files=existing_files,
action=options.action,
overwrite=options.overwrite,
)
@@ -723,6 +920,55 @@ class BoardAgentLifecycleManager(BaseAgentLifecycleManager):
raise ValueError(msg)
return _build_context(agent, board, self._gateway, auth_token, user)
async def _augment_context(
self,
*,
agent: Agent,
context: dict[str, str],
) -> dict[str, str]:
context = dict(context)
if agent.is_board_lead:
context["directory_role_soul_markdown"] = ""
context["directory_role_soul_source_url"] = ""
return context
role = (context.get("identity_role") or "").strip()
markdown, source_url = await _resolve_role_soul_markdown(role)
context["directory_role_soul_markdown"] = markdown
context["directory_role_soul_source_url"] = source_url
return context
def _template_overrides(self, agent: Agent) -> dict[str, str] | None:
overrides = dict(BOARD_SHARED_TEMPLATE_MAP)
if agent.is_board_lead:
overrides.update(LEAD_TEMPLATE_MAP)
return overrides
def _file_names(self, agent: Agent) -> set[str]:
if agent.is_board_lead:
return set(LEAD_GATEWAY_FILES)
return super()._file_names(agent)
def _allow_stale_file_deletion(self, agent: Agent) -> bool:
return bool(agent.is_board_lead)
def _stale_file_candidates(self, agent: Agent) -> set[str]:
if not agent.is_board_lead:
return set()
return (
set(DEFAULT_GATEWAY_FILES)
| set(LEAD_GATEWAY_FILES)
| {
"USER.md",
"ROUTING.md",
"LEARNINGS.md",
"ROLE.md",
"WORKFLOW.md",
"STATUS.md",
"APIS.md",
}
)
class GatewayMainAgentLifecycleManager(BaseAgentLifecycleManager):
"""Provisioning manager for organization gateway-main agents."""
@@ -741,13 +987,15 @@ class GatewayMainAgentLifecycleManager(BaseAgentLifecycleManager):
_ = board
return _build_main_context(agent, self._gateway, auth_token, user)
def _template_overrides(self) -> dict[str, str] | None:
def _template_overrides(self, agent: Agent) -> dict[str, str] | None:
_ = agent
return MAIN_TEMPLATE_MAP
def _preserve_files(self) -> set[str]:
def _preserve_files(self, agent: Agent) -> set[str]:
_ = agent
# For gateway-main agents, USER.md is system-managed (derived from org/user context),
# so keep it in sync even during updates.
preserved = super()._preserve_files()
preserved = super()._preserve_files(agent)
preserved.discard("USER.md")
return preserved
@@ -757,7 +1005,12 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
return OpenClawGatewayControlPlane(
GatewayClientConfig(url=gateway.url, token=gateway.token),
GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
),
)
@@ -791,8 +1044,8 @@ def _should_include_bootstrap(
def _wakeup_text(agent: Agent, *, verb: str) -> str:
return (
f"Hello {agent.name}. Your workspace has been {verb}.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
"Start the agent. If BOOTSTRAP.md exists, read it first, then read AGENTS.md. "
"Begin heartbeats after startup."
)
@@ -824,6 +1077,7 @@ class OpenClawGatewayProvisioner:
user: User | None,
action: str = "provision",
force_bootstrap: bool = False,
overwrite: bool = False,
reset_session: bool = False,
wake: bool = True,
deliver_wakeup: bool = True,
@@ -833,7 +1087,7 @@ class OpenClawGatewayProvisioner:
Lifecycle steps (same for all agent types):
1) create agent (idempotent)
2) set/update all template files (best-effort for unsupported files)
2) set/update all template files
3) wake the agent session (chat.send)
"""
@@ -867,7 +1121,11 @@ class OpenClawGatewayProvisioner:
session_key=session_key,
auth_token=auth_token,
user=user,
options=ProvisionOptions(action=action, force_bootstrap=force_bootstrap),
options=ProvisionOptions(
action=action,
force_bootstrap=force_bootstrap,
overwrite=overwrite,
),
session_label=agent.name or "Gateway Agent",
)
@@ -881,7 +1139,12 @@ class OpenClawGatewayProvisioner:
if not wake:
return
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
client_config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
await ensure_session(session_key, config=client_config, label=agent.name)
verb = wakeup_verb or ("provisioned" if action == "provision" else "updated")
await send_message(
@@ -915,7 +1178,11 @@ class OpenClawGatewayProvisioner:
agent_gateway_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
else:
agent_gateway_id = _agent_key(agent)
await control_plane.delete_agent(agent_gateway_id, delete_files=delete_files)
try:
await control_plane.delete_agent(agent_gateway_id, delete_files=delete_files)
except OpenClawGatewayError as exc:
if not _is_missing_agent_error(exc):
raise
if delete_session:
if agent.board_id is None: