feat: created colony inherit skills and tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}/."""
|
||||
|
||||
Reference in New Issue
Block a user