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]:
|
def _role_instruction_context(agent: Agent) -> dict[str, str]:
|
||||||
role = _identity_context(agent).get("identity_role", "")
|
role = _identity_context(agent).get("identity_role", "")
|
||||||
|
role_markdown = _local_role_instruction_markdown(role)
|
||||||
return {
|
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."""
|
- 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(
|
def _select_role_soul_ref(
|
||||||
refs: list[souls_directory.SoulRef],
|
refs: list[souls_directory.SoulRef],
|
||||||
*,
|
*,
|
||||||
|
|
@ -958,6 +1006,25 @@ class BaseAgentLifecycleManager(ABC):
|
||||||
for name, content in rendered.items():
|
for name, content in rendered.items():
|
||||||
if content == "":
|
if content == "":
|
||||||
continue
|
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,
|
# 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
|
# the gateway may pre-create defaults for USER/MEMORY/etc, and we still want to
|
||||||
# apply Mission Control's templates.
|
# apply Mission Control's templates.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
- Communication Style: {{ identity_communication_style }}
|
- Communication Style: {{ identity_communication_style }}
|
||||||
- Emoji: {{ identity_emoji }}
|
- 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 %}
|
{% if local_role_instructions %}
|
||||||
{{ 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 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 %}
|
{% if is_lead %}
|
||||||
# SOUL.md - Who You Are
|
# 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_autonomy_level`, `identity_verbosity`, `identity_output_format`, `identity_update_cadence`
|
||||||
- `identity_purpose`, `identity_personality`, `identity_custom_instructions`
|
- `identity_purpose`, `identity_personality`, `identity_custom_instructions`
|
||||||
- `local_role_instruction_markdown`: deterministic local instructions derived from `identity_role`
|
- `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
|
### Board-agent-only keys
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ def test_rendered_agent_files_include_local_role_instructions() -> None:
|
||||||
name="Backend Agent",
|
name="Backend Agent",
|
||||||
identity_profile={"role": "Backend Code"},
|
identity_profile={"role": "Backend Code"},
|
||||||
)
|
)
|
||||||
|
role_markdown = agent_provisioning._local_role_instruction_markdown("Backend Code")
|
||||||
context = {
|
context = {
|
||||||
"agent_name": agent.name,
|
"agent_name": agent.name,
|
||||||
"agent_id": str(agent.id),
|
"agent_id": str(agent.id),
|
||||||
|
|
@ -145,8 +146,9 @@ def test_rendered_agent_files_include_local_role_instructions() -> None:
|
||||||
"is_board_lead": "false",
|
"is_board_lead": "false",
|
||||||
"directory_role_soul_markdown": "",
|
"directory_role_soul_markdown": "",
|
||||||
"directory_role_soul_source_url": "",
|
"directory_role_soul_source_url": "",
|
||||||
"local_role_instruction_markdown": agent_provisioning._local_role_instruction_markdown(
|
"local_role_instruction_markdown": role_markdown,
|
||||||
"Backend Code"
|
"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["IDENTITY.md"]
|
||||||
assert "## Role Instructions - Backend Code" in rendered["SOUL.md"]
|
assert "## Role Instructions - Backend Code" in rendered["SOUL.md"]
|
||||||
assert "backend tests" 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
|
@dataclass
|
||||||
|
|
@ -449,6 +479,124 @@ async def test_set_agent_files_update_preserves_nonmissing_user_md():
|
||||||
assert cp.writes == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_set_agent_files_update_overwrite_writes_preserved_user_md():
|
async def test_set_agent_files_update_overwrite_writes_preserved_user_md():
|
||||||
class _ControlPlaneStub:
|
class _ControlPlaneStub:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue