From fe7ce20956a90011900751ca02b6e5f8bc8c2bf2 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 00:59:47 -0500 Subject: [PATCH] fix(script): provision --- backend/app/services/openclaw/provisioning.py | 69 +++++++- backend/templates/BOARD_IDENTITY.md.j2 | 2 +- backend/templates/BOARD_SOUL.md.j2 | 2 +- backend/templates/README.md | 1 + .../tests/test_agent_provisioning_utils.py | 152 +++++++++++++++++- 5 files changed, 221 insertions(+), 5 deletions(-) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 7b63274..02545cc 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -299,8 +299,10 @@ def _identity_context(agent: Agent) -> dict[str, str]: def _role_instruction_context(agent: Agent) -> dict[str, str]: role = _identity_context(agent).get("identity_role", "") + role_markdown = _local_role_instruction_markdown(role) return { - "local_role_instruction_markdown": _local_role_instruction_markdown(role), + "local_role_instruction_markdown": role_markdown, + "local_role_instruction_block": _managed_role_instruction_block(role_markdown), } @@ -406,6 +408,52 @@ def _local_role_instruction_markdown(role: str) -> str: - Capture role-specific assumptions, risks, and verification evidence in task comments.""" +def _managed_role_instruction_block(markdown: str) -> str: + markdown = markdown.strip() + if not markdown: + return "" + return f"{_ROLE_INSTRUCTION_START}\n{markdown}\n{_ROLE_INSTRUCTION_END}" + + +def _extract_managed_role_instruction_block(content: str) -> str: + match = _ROLE_INSTRUCTION_RE.search(content) + return match.group(0).strip() if match else "" + + +def _merge_managed_role_instruction_block(existing_content: str, rendered_content: str) -> str: + managed_block = _extract_managed_role_instruction_block(rendered_content) + if not managed_block: + return existing_content + if _ROLE_INSTRUCTION_RE.search(existing_content): + return _ROLE_INSTRUCTION_RE.sub(managed_block, existing_content, count=1) + return f"{existing_content.rstrip()}\n\n{managed_block}\n" + + +def _agent_file_content_from_payload(payload: object) -> str | None: + if isinstance(payload, str): + return payload + if not isinstance(payload, dict): + return None + + content = payload.get("content") + if isinstance(content, str): + return content + + file_payload = payload.get("file") + if isinstance(file_payload, dict): + content = file_payload.get("content") + if isinstance(content, str): + return content + + data_payload = payload.get("data") + if isinstance(data_payload, dict): + content = data_payload.get("content") + if isinstance(content, str): + return content + + return None + + def _select_role_soul_ref( refs: list[souls_directory.SoulRef], *, @@ -958,6 +1006,25 @@ class BaseAgentLifecycleManager(ABC): for name, content in rendered.items(): if content == "": continue + if action in {"provision", "update"} and not overwrite and name in _ROLE_INSTRUCTION_PATCH_FILES: + entry = existing_files.get(name) + if entry and not bool(entry.get("missing")): + payload = await self._control_plane.get_agent_file_payload( + agent_id=agent_id, + name=name, + ) + existing_content = _agent_file_content_from_payload(payload) + if existing_content is None: + logger.warning( + "Skipping role-instruction patch for %s:%s; gateway returned " + "unreadable file payload", + agent_id, + name, + ) + continue + content = _merge_managed_role_instruction_block(existing_content, content) + if content == existing_content: + continue # Preserve "editable" files only during updates. During first-time provisioning, # the gateway may pre-create defaults for USER/MEMORY/etc, and we still want to # apply Mission Control's templates. diff --git a/backend/templates/BOARD_IDENTITY.md.j2 b/backend/templates/BOARD_IDENTITY.md.j2 index 451f654..7e9cc7c 100644 --- a/backend/templates/BOARD_IDENTITY.md.j2 +++ b/backend/templates/BOARD_IDENTITY.md.j2 @@ -9,7 +9,7 @@ - Communication Style: {{ identity_communication_style }} - Emoji: {{ identity_emoji }} -{% set local_role_instructions = local_role_instruction_markdown | default("") | trim %} +{% set local_role_instructions = local_role_instruction_block | default("") | trim %} {% if local_role_instructions %} {{ local_role_instructions }} diff --git a/backend/templates/BOARD_SOUL.md.j2 b/backend/templates/BOARD_SOUL.md.j2 index 5ec7f50..e6c55c5 100644 --- a/backend/templates/BOARD_SOUL.md.j2 +++ b/backend/templates/BOARD_SOUL.md.j2 @@ -1,5 +1,5 @@ {% set is_lead = (is_board_lead | default(false) | string | lower) in ["true", "1", "yes"] %} -{% set local_role_instructions = local_role_instruction_markdown | default("") | trim %} +{% set local_role_instructions = local_role_instruction_block | default("") | trim %} {% if is_lead %} # SOUL.md - Who You Are diff --git a/backend/templates/README.md b/backend/templates/README.md index f88793a..84e55ff 100644 --- a/backend/templates/README.md +++ b/backend/templates/README.md @@ -125,6 +125,7 @@ This avoids relying on startup hooks to populate `api/openapi.json`. - `identity_autonomy_level`, `identity_verbosity`, `identity_output_format`, `identity_update_cadence` - `identity_purpose`, `identity_personality`, `identity_custom_instructions` - `local_role_instruction_markdown`: deterministic local instructions derived from `identity_role` +- `local_role_instruction_block`: the same instructions wrapped in Mission Control markers so provisioning can patch existing agent files without replacing local content ### Board-agent-only keys diff --git a/backend/tests/test_agent_provisioning_utils.py b/backend/tests/test_agent_provisioning_utils.py index 247d3e3..716f5ba 100644 --- a/backend/tests/test_agent_provisioning_utils.py +++ b/backend/tests/test_agent_provisioning_utils.py @@ -133,6 +133,7 @@ def test_rendered_agent_files_include_local_role_instructions() -> None: name="Backend Agent", identity_profile={"role": "Backend Code"}, ) + role_markdown = agent_provisioning._local_role_instruction_markdown("Backend Code") context = { "agent_name": agent.name, "agent_id": str(agent.id), @@ -145,8 +146,9 @@ def test_rendered_agent_files_include_local_role_instructions() -> None: "is_board_lead": "false", "directory_role_soul_markdown": "", "directory_role_soul_source_url": "", - "local_role_instruction_markdown": agent_provisioning._local_role_instruction_markdown( - "Backend Code" + "local_role_instruction_markdown": role_markdown, + "local_role_instruction_block": agent_provisioning._managed_role_instruction_block( + role_markdown ), } @@ -164,6 +166,34 @@ def test_rendered_agent_files_include_local_role_instructions() -> None: assert "## Role Instructions - Backend Code" in rendered["IDENTITY.md"] assert "## Role Instructions - Backend Code" in rendered["SOUL.md"] assert "backend tests" in rendered["SOUL.md"] + assert "mission-control:role-instructions:start" in rendered["SOUL.md"] + + +def test_merge_managed_role_instruction_block_preserves_existing_content() -> None: + existing = "# SOUL.md - Who You Are\n\nCustom persona stays here.\n" + rendered = agent_provisioning._managed_role_instruction_block( + agent_provisioning._local_role_instruction_markdown("Security Review") + ) + + merged = agent_provisioning._merge_managed_role_instruction_block(existing, rendered) + + assert "Custom persona stays here." in merged + assert "## Role Instructions - Security Review" in merged + assert "mission-control:role-instructions:start" in merged + + +def test_merge_managed_role_instruction_block_replaces_existing_managed_block() -> None: + old_block = agent_provisioning._managed_role_instruction_block("## Old Role\n\n- Old.") + new_block = agent_provisioning._managed_role_instruction_block( + agent_provisioning._local_role_instruction_markdown("UI") + ) + existing = f"# IDENTITY.md\n\nCustom identity.\n\n{old_block}\n" + + merged = agent_provisioning._merge_managed_role_instruction_block(existing, new_block) + + assert "Custom identity." in merged + assert "## Role Instructions - UI" in merged + assert "## Old Role" not in merged @dataclass @@ -449,6 +479,124 @@ async def test_set_agent_files_update_preserves_nonmissing_user_md(): assert cp.writes == [] +@pytest.mark.asyncio +async def test_set_agent_files_update_patches_existing_identity_without_overwriting(): + class _ControlPlaneStub: + def __init__(self): + self.writes: list[tuple[str, str]] = [] + + async def get_agent_file_payload(self, *, agent_id, name): + _ = (agent_id, name) + return { + "content": "# IDENTITY.md - Who Am I?\n\nCustom identity stays.\n", + } + + async def set_agent_file(self, *, agent_id, name, content): + _ = agent_id + self.writes.append((name, content)) + + @dataclass + class _GatewayTiny: + id: UUID + name: str + url: str + token: str | None + workspace_root: str + allow_insecure_tls: bool = False + disable_device_pairing: bool = False + + class _Manager(agent_provisioning.BaseAgentLifecycleManager): + def _agent_id(self, agent): + return "agent-x" + + def _build_context(self, *, agent, auth_token, user, board): + return {} + + gateway = _GatewayTiny( + id=uuid4(), + name="G", + url="ws://x", + token=None, + workspace_root="/tmp", + ) + cp = _ControlPlaneStub() + mgr = _Manager(gateway, cp) # type: ignore[arg-type] + rendered = agent_provisioning._managed_role_instruction_block( + agent_provisioning._local_role_instruction_markdown("Code Review") + ) + + await mgr._set_agent_files( + agent_id="agent-x", + rendered={"IDENTITY.md": rendered}, + existing_files={"IDENTITY.md": {"name": "IDENTITY.md", "missing": False}}, + action="update", + ) + + assert len(cp.writes) == 1 + name, content = cp.writes[0] + assert name == "IDENTITY.md" + assert "Custom identity stays." in content + assert "## Role Instructions - Code Review" in content + + +@pytest.mark.asyncio +async def test_set_agent_files_provision_patches_existing_soul_without_overwriting(): + class _ControlPlaneStub: + def __init__(self): + self.writes: list[tuple[str, str]] = [] + + async def get_agent_file_payload(self, *, agent_id, name): + _ = (agent_id, name) + return "# SOUL.md - Who You Are\n\nCustom soul stays.\n" + + async def set_agent_file(self, *, agent_id, name, content): + _ = agent_id + self.writes.append((name, content)) + + @dataclass + class _GatewayTiny: + id: UUID + name: str + url: str + token: str | None + workspace_root: str + allow_insecure_tls: bool = False + disable_device_pairing: bool = False + + class _Manager(agent_provisioning.BaseAgentLifecycleManager): + def _agent_id(self, agent): + return "agent-x" + + def _build_context(self, *, agent, auth_token, user, board): + return {} + + gateway = _GatewayTiny( + id=uuid4(), + name="G", + url="ws://x", + token=None, + workspace_root="/tmp", + ) + cp = _ControlPlaneStub() + mgr = _Manager(gateway, cp) # type: ignore[arg-type] + rendered = agent_provisioning._managed_role_instruction_block( + agent_provisioning._local_role_instruction_markdown("Security Review") + ) + + await mgr._set_agent_files( + agent_id="agent-x", + rendered={"SOUL.md": rendered}, + existing_files={"SOUL.md": {"name": "SOUL.md", "missing": False}}, + action="provision", + ) + + assert len(cp.writes) == 1 + name, content = cp.writes[0] + assert name == "SOUL.md" + assert "Custom soul stays." in content + assert "## Role Instructions - Security Review" in content + + @pytest.mark.asyncio async def test_set_agent_files_update_overwrite_writes_preserved_user_md(): class _ControlPlaneStub: