feat: created colony inherit skills and tools

This commit is contained in:
Richard Tang
2026-04-21 19:23:33 -07:00
parent 0fd96d410e
commit 0ba1fa8262
5 changed files with 165 additions and 7 deletions
+33
View File
@@ -1577,6 +1577,39 @@ async def fork_session_into_colony(
}
metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
# ── 4a. Inherit the queen's tool allowlist into the colony ───
# A colony forked from a curated queen should start with the same
# tool surface (otherwise the colony silently falls back to its own
# "allow every MCP tool" default, undoing the parent's curation).
# We copy the queen's LIVE effective allowlist so the snapshot
# reflects whatever was in force the moment the user clicked "Create
# Colony". Users can further narrow the colony via the Tool Library.
# Skip the write when the queen is on allow-all (None) so the colony
# keeps the same semantics without creating an inert sidecar.
try:
queen_enabled = getattr(
getattr(session, "phase_state", None),
"enabled_mcp_tools",
None,
)
if isinstance(queen_enabled, list):
from framework.host.colony_tools_config import update_colony_tools_config
update_colony_tools_config(colony_name, list(queen_enabled))
logger.info(
"Inherited queen allowlist into colony '%s' (%d tools)",
colony_name,
len(queen_enabled),
)
except Exception:
# Inheritance is best-effort — don't let a tools.json hiccup
# abort colony creation.
logger.warning(
"Failed to inherit queen allowlist into colony '%s'",
colony_name,
exc_info=True,
)
# ── 5. Update source queen session meta.json ─────────────────
# Link the originating session back to the colony for discovery.
source_meta_path = source_queen_dir / "meta.json"
@@ -251,6 +251,35 @@ async def test_tools_index_lists_colonies(colony_dir):
assert entries[name]["has_allowlist"] is False
def test_queen_allowlist_inherits_into_new_colony(tmp_path, monkeypatch):
"""A colony forked with a curated queen inherits her allowlist.
Exercises the inheritance hook in
``routes_execution.fork_session_into_colony`` without running the
full fork machinery we just call
``update_colony_tools_config`` the same way the hook does and
assert the colony's ``tools.json`` matches the queen's live list.
"""
colonies = tmp_path / "colonies"
colonies.mkdir()
monkeypatch.setattr("framework.host.colony_tools_config.COLONIES_DIR", colonies)
from framework.host.colony_tools_config import (
load_colony_tools_config,
update_colony_tools_config,
)
colony_name = "forked_child"
(colonies / colony_name).mkdir()
# Simulate: queen has a curated allowlist (e.g. role default resolved
# to a concrete list). The inheritance hook copies it verbatim.
queen_live_allowlist = ["read_file", "web_scrape", "csv_read"]
update_colony_tools_config(colony_name, list(queen_live_allowlist))
assert load_colony_tools_config(colony_name) == queen_live_allowlist
def test_legacy_metadata_field_migrates_to_sidecar(colony_dir):
"""A legacy enabled_mcp_tools field in metadata.json is hoisted to tools.json."""
colonies_dir, name = colony_dir
+12
View File
@@ -64,6 +64,18 @@ class OverrideEntry:
created_at: datetime | None = None
created_by: str | None = None
def clone(self) -> OverrideEntry:
"""Return a deep-enough copy (dict fields are re-allocated)."""
return OverrideEntry(
enabled=self.enabled,
provenance=self.provenance,
trust=self.trust,
param_overrides=dict(self.param_overrides),
notes=self.notes,
created_at=self.created_at,
created_by=self.created_by,
)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"provenance": str(self.provenance)}
if self.enabled is not None:
+31 -6
View File
@@ -1616,14 +1616,39 @@ def register_queen_lifecycle_tools(
}
)
# Register the write in the colony's override store so the UI can
# edit/toggle it and :func:`SkillsManager._apply_overrides` carries
# the right provenance.
# Seed the colony's override ledger from the queen's current
# state so the colony inherits everything she had enabled (preset
# capability packs, toggled-off framework defaults, etc.) at fork
# time. The colony then owns its own copy — later queen edits
# don't retroactively alter this colony's skill surface.
# On top of the seed we upsert the newly-written skill with
# QUEEN_CREATED provenance so the UI renders + edits it properly.
try:
from framework.config import QUEENS_DIR
overrides_path = colony_dir / "skills_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}")
queen_id = getattr(session, "queen_name", None) or "unknown"
store.upsert(
colony_store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}")
queen_overrides_path = QUEENS_DIR / queen_id / "skills_overrides.json"
if queen_overrides_path.exists():
queen_store = SkillOverrideStore.load(
queen_overrides_path, scope_label=f"queen:{queen_id}"
)
# Shallow clone: queen's explicit toggles + master switch
# become the colony's starting state. Tombstones propagate
# so a queen-deleted UI skill doesn't resurrect here.
colony_store.all_defaults_disabled = queen_store.all_defaults_disabled
for sname, entry in queen_store.overrides.items():
# Don't overwrite an entry the colony already set
# (rare on fresh fork; matters if this is a re-fork).
if sname in colony_store.overrides:
continue
colony_store.upsert(sname, entry.clone())
for sname in queen_store.deleted_ui_skills:
colony_store.deleted_ui_skills.add(sname)
colony_store.upsert(
draft.name,
OverrideEntry(
enabled=True,
@@ -1632,7 +1657,7 @@ def register_queen_lifecycle_tools(
created_by=f"queen:{queen_id}",
),
)
store.save()
colony_store.save()
except Exception:
# Registration is best-effort; discovery still surfaces the
# skill as project-scope even if the ledger fails to update.
+60 -1
View File
@@ -38,7 +38,7 @@ from framework.tools.queen_lifecycle_tools import register_queen_lifecycle_tools
class _FakeSession:
def __init__(self, sid: str = "session_test_create_colony"):
def __init__(self, sid: str = "session_test_create_colony", queen_name: str = "sophia"):
self.id = sid
self.colony = None
self.colony_runtime = None
@@ -46,6 +46,7 @@ class _FakeSession:
self.worker_path = None
self.available_triggers: dict = {}
self.active_trigger_ids: set = set()
self.queen_name = queen_name
def _make_executor():
@@ -161,6 +162,64 @@ async def test_happy_path_emits_colony_created_event(patched_home: Path, patched
assert ev.data.get("is_new") is True
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_colony_inherits_queen_override_state(
patched_home: Path, patched_fork: list[dict]
) -> None:
"""Seed the colony's skills_overrides.json from the queen's at fork
time. A queen who enabled a preset (e.g. hive.x-automation) before
calling create_colony must produce a colony that also has it
enabled without needing a second UI toggle on the colony page.
"""
from framework.config import QUEENS_DIR
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
)
# Pre-seed the queen's override file.
queen_home = QUEENS_DIR / "sophia"
queen_home.mkdir(parents=True, exist_ok=True)
qstore = SkillOverrideStore.load(queen_home / "skills_overrides.json")
qstore.upsert(
"hive.x-automation",
OverrideEntry(enabled=True, provenance=Provenance.PRESET),
)
qstore.upsert(
"hive.note-taking",
OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK),
)
qstore.save()
executor, _ = _make_executor()
payload = await _call(
executor,
colony_name="inheritance_check",
task="t",
skill_name="bespoke-skill",
skill_description="Written during this create_colony call.",
skill_body=_DEFAULT_BODY,
)
assert payload.get("status") == "created", f"Tool error: {payload}"
colony_overrides = (
patched_home / ".hive" / "colonies" / "inheritance_check" / "skills_overrides.json"
)
cstore = SkillOverrideStore.load(colony_overrides)
# Inherited entries from the queen:
assert cstore.get("hive.x-automation").enabled is True
assert cstore.get("hive.note-taking").enabled is False
# Newly-written skill is also registered with queen_created provenance:
bespoke = cstore.get("bespoke-skill")
assert bespoke is not None
assert bespoke.provenance == Provenance.QUEEN_CREATED
assert bespoke.enabled is True
@pytest.mark.asyncio
async def test_happy_path_materializes_skill_under_colony_dir(patched_home: Path, patched_fork: list[dict]) -> None:
"""Inline skill content is written to ~/.hive/colonies/{colony}/.hive/skills/{name}/."""