Skip to content

Commit 54ac698

Browse files
DeepfreezechillBrian KrafftCopilot
authored
feat(P4/4.1): extract ToolRegistry from OpenSpace (#31)
* feat(P4/4.1): extract ToolRegistry from OpenSpace Extract skill discovery, LLM-based selection, and context injection into a focused ToolRegistry class (openspace/tool_registry.py). Moved methods: - _init_skill_registry() → ToolRegistry.discover() - _select_and_inject_skills() → ToolRegistry.select_and_inject() - _get_skill_selection_llm() → ToolRegistry._get_selection_llm() OpenSpace now delegates to ToolRegistry in initialize() and execute(). All 1,356 existing tests pass unchanged. 15 new ToolRegistry tests added. tool_layer.py: 788 → 735 lines (-53) tool_registry.py: 203 lines (new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(P4/4.1): address review findings from /8eyes + /collab Round 1 fixes: - Fix llm_kwargs spread order so explicit timeout/max_retries take precedence (L3) - Add skills.enabled guard inside ToolRegistry.discover() (F4) - Remove dead agent-None guard in select_and_inject (M2) - Expand test suite: 15 → 23 tests covering: - skills.enabled=False path - grounding_config=None path - store quality metrics forwarding - store exception swallowing - no-LLM skip path - backend_scope forwarding - LLM returns None when all options exhausted - agent=None fallback to llm_client - llm_kwargs forwarding verification - Strengthen assertion on _tool_registry initial value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dbc189a commit 54ac698

4 files changed

Lines changed: 1563 additions & 158 deletions

File tree

openspace/tool_layer.py

Lines changed: 21 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from openspace.recording import RecordingManager
1717
from openspace.skill_engine import ExecutionAnalyzer, SkillRegistry, SkillStore
1818
from openspace.skill_engine.evolver import SkillEvolver
19+
from openspace.tool_registry import ToolRegistry
1920
from openspace.utils.logging import Logger
2021

2122
logger = Logger.get_logger(__name__)
@@ -126,6 +127,7 @@ def __init__(
126127
self._grounding_agent: Optional[GroundingAgent] = None
127128
self._recording_manager: Optional[RecordingManager] = None
128129
self._skill_registry: Optional[SkillRegistry] = None
130+
self._tool_registry: Optional[ToolRegistry] = None
129131
self._skill_store: Optional[SkillStore] = None
130132
self._execution_analyzer: Optional[ExecutionAnalyzer] = None
131133
self._skill_evolver: Optional[SkillEvolver] = None
@@ -336,11 +338,18 @@ async def initialize(self) -> None:
336338

337339
# Initialize SkillRegistry (settings from config_grounding.json → skills)
338340
if self._grounding_config and self._grounding_config.skills.enabled:
339-
self._skill_registry = self._init_skill_registry()
340-
if self._skill_registry:
341+
self._tool_registry = ToolRegistry(
342+
config=self.config,
343+
grounding_config=self._grounding_config,
344+
llm_client=self._llm_client,
345+
)
346+
if self._tool_registry.discover():
347+
self._skill_registry = self._tool_registry.registry
341348
skills = self._skill_registry.list_skills()
342349
logger.info(f"✓ Skills: {len(skills)} discovered")
343350
self._grounding_agent.set_skill_registry(self._skill_registry)
351+
else:
352+
self._skill_registry = None
344353

345354
# Initialize ExecutionAnalyzer (requires recording + skills)
346355
if self.config.enable_recording and self._skill_registry:
@@ -506,8 +515,13 @@ async def execute(
506515
has_skills = False
507516

508517
# Phase 1: Skill-guided execution
509-
if self._skill_registry:
510-
has_skills = await self._select_and_inject_skills(task)
518+
if self._skill_registry and self._tool_registry:
519+
has_skills = await self._tool_registry.select_and_inject(
520+
task,
521+
agent=self._grounding_agent,
522+
store=self._skill_store,
523+
recording_mgr=self._recording_manager,
524+
)
511525

512526
if has_skills:
513527
logger.info(f"[Phase 1 — Skill] Executing with skill guidance (max {max_iterations} iterations)...")
@@ -665,160 +679,9 @@ async def execute(
665679

666680
return final_result
667681

668-
# Skills helpers
669-
def _init_skill_registry(self) -> Optional[SkillRegistry]:
670-
"""Build and populate the SkillRegistry from configured directories.
671-
672-
Discovery order (earlier wins on name collision):
673-
1. ``OPENSPACE_HOST_SKILL_DIRS`` env — host agent skill directories
674-
2. ``config_grounding.json → skills.skill_dirs`` — user-specified
675-
3. ``openspace/skills/`` — built-in skills (always present)
676-
677-
``OPENSPACE_HOST_SKILL_DIRS`` is also handled by ``mcp_server.py``
678-
for the MCP transport path, but we process it here too so that
679-
standalone mode (``python -m openspace``) gets the same skills
680-
discovered and synced to the DB for quality tracking / evolution.
681-
"""
682-
skill_paths: List[Path] = []
683-
skill_cfg = self._grounding_config.skills if self._grounding_config else None
684-
685-
# 1. Host agent skill directories from env (standalone mode support)
686-
import os
687-
688-
host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
689-
if host_dirs_raw:
690-
for d in host_dirs_raw.split(","):
691-
d = d.strip()
692-
if not d:
693-
continue
694-
p = Path(d)
695-
if p.exists():
696-
skill_paths.append(p)
697-
logger.info(f"Host skill dir (from env): {p}")
698-
else:
699-
logger.warning(f"Host skill dir does not exist: {d}")
700-
701-
# 2. User-specified skill directories from config_grounding.json
702-
if skill_cfg and skill_cfg.skill_dirs:
703-
for d in skill_cfg.skill_dirs:
704-
p = Path(d)
705-
if p in skill_paths:
706-
continue # Already added via OPENSPACE_HOST_SKILL_DIRS
707-
if p.exists():
708-
skill_paths.append(p)
709-
else:
710-
logger.warning(f"Configured skill dir does not exist: {d}")
711-
712-
# 3. Built-in skills (openspace/skills/)
713-
builtin_skills = Path(__file__).resolve().parent / "skills"
714-
if builtin_skills.exists():
715-
skill_paths.append(builtin_skills)
716-
717-
if not skill_paths:
718-
logger.debug("No skill directories found, skills disabled")
719-
return None
720-
721-
registry = SkillRegistry(skill_dirs=skill_paths)
722-
registry.discover()
723-
return registry
724-
725-
async def _select_and_inject_skills(
726-
self,
727-
task: str,
728-
) -> bool:
729-
"""Select skills for task via LLM, inject into GroundingAgent.
730-
731-
When the registry has many skills, a BM25 + embedding pre-filter
732-
narrows the candidate set before LLM selection (see
733-
``SkillRegistry.select_skills_with_llm``).
734-
735-
Only selected skills are injected (full SKILL.md content).
736-
Returns True if at least one active skill was injected.
737-
"""
738-
if not self._skill_registry or not self._grounding_agent:
739-
return False
740-
741-
selection_record = None
742-
743-
# LLM-based skill selection (preferred)
744-
skill_cfg = self._grounding_config.skills if self._grounding_config else None
745-
max_select = skill_cfg.max_select if skill_cfg else 2
746-
skill_llm = self._get_skill_selection_llm()
747-
748-
# Fetch quality metrics so the selector can filter/annotate
749-
skill_quality: Optional[Dict[str, Dict[str, Any]]] = None
750-
if self._skill_store:
751-
try:
752-
rows = self._skill_store.get_summary(active_only=True)
753-
skill_quality = {
754-
r["skill_id"]: {
755-
"total_selections": r.get("total_selections", 0),
756-
"total_applied": r.get("total_applied", 0),
757-
"total_completions": r.get("total_completions", 0),
758-
"total_fallbacks": r.get("total_fallbacks", 0),
759-
}
760-
for r in rows
761-
}
762-
except Exception as e:
763-
logger.debug(f"Could not load skill quality metrics: {e}")
764-
765-
if skill_llm:
766-
selected, selection_record = await self._skill_registry.select_skills_with_llm(
767-
task,
768-
llm_client=skill_llm,
769-
max_skills=max_select,
770-
skill_quality=skill_quality,
771-
)
772-
else:
773-
# No LLM client — skip skill selection entirely
774-
logger.info("No LLM client available for skill selection — proceeding without skills")
775-
selected = []
776-
selection_record = {
777-
"method": "no_llm",
778-
"task": task[:500],
779-
"available_skills": [s.skill_id for s in self._skill_registry.list_skills()],
780-
"selected": [],
781-
}
782-
783-
# Record skill selection to metadata.json
784-
if self._recording_manager and selection_record:
785-
# Add model info to the record
786-
selection_record["model"] = skill_llm.model if skill_llm else "keyword_only"
787-
await RecordingManager.record_skill_selection(selection_record)
788-
789-
if not selected:
790-
self._grounding_agent.clear_skill_context()
791-
return False
792-
793-
# Inject active skills (full SKILL.md content, backend-aware)
794-
agent_backends = self._grounding_agent.backend_scope if self._grounding_agent else None
795-
context_text = self._skill_registry.build_context_injection(selected, backends=agent_backends)
796-
skill_ids = [s.skill_id for s in selected]
797-
self._grounding_agent.set_skill_context(context_text, skill_ids)
798-
logger.info(f"Injected {len(selected)} active skill(s): {skill_ids}")
799-
800-
return True
801-
802-
def _get_skill_selection_llm(self) -> Optional[LLMClient]:
803-
"""Get the LLM client to use for skill selection.
804-
805-
Priority: config.skill_registry_model > tool_retrieval_model > llm_model.
806-
"""
807-
# 1. Dedicated skill selection model (OpenSpaceConfig.skill_registry_model)
808-
if self.config.skill_registry_model:
809-
return LLMClient(
810-
model=self.config.skill_registry_model,
811-
timeout=30.0, # skill selection should be fast
812-
max_retries=2,
813-
**self.config.llm_kwargs,
814-
)
815-
816-
# 2. Tool retrieval model
817-
if hasattr(self._grounding_agent, "_tool_retrieval_llm") and self._grounding_agent._tool_retrieval_llm:
818-
return self._grounding_agent._tool_retrieval_llm
819-
820-
# 3. Main LLM client
821-
return self._llm_client
682+
# NOTE: _init_skill_registry, _select_and_inject_skills, and
683+
# _get_skill_selection_llm have been extracted to ToolRegistry
684+
# (openspace/tool_registry.py) in Epic 4.1.
822685

823686
async def _maybe_analyze_execution(
824687
self,

0 commit comments

Comments
 (0)