fix(script): provision
This commit is contained in:
parent
36025c6c67
commit
fe7ce20956
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue