|
16 | 16 | from openspace.recording import RecordingManager |
17 | 17 | from openspace.skill_engine import ExecutionAnalyzer, SkillRegistry, SkillStore |
18 | 18 | from openspace.skill_engine.evolver import SkillEvolver |
| 19 | +from openspace.tool_registry import ToolRegistry |
19 | 20 | from openspace.utils.logging import Logger |
20 | 21 |
|
21 | 22 | logger = Logger.get_logger(__name__) |
@@ -126,6 +127,7 @@ def __init__( |
126 | 127 | self._grounding_agent: Optional[GroundingAgent] = None |
127 | 128 | self._recording_manager: Optional[RecordingManager] = None |
128 | 129 | self._skill_registry: Optional[SkillRegistry] = None |
| 130 | + self._tool_registry: Optional[ToolRegistry] = None |
129 | 131 | self._skill_store: Optional[SkillStore] = None |
130 | 132 | self._execution_analyzer: Optional[ExecutionAnalyzer] = None |
131 | 133 | self._skill_evolver: Optional[SkillEvolver] = None |
@@ -336,11 +338,18 @@ async def initialize(self) -> None: |
336 | 338 |
|
337 | 339 | # Initialize SkillRegistry (settings from config_grounding.json → skills) |
338 | 340 | 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 |
341 | 348 | skills = self._skill_registry.list_skills() |
342 | 349 | logger.info(f"✓ Skills: {len(skills)} discovered") |
343 | 350 | self._grounding_agent.set_skill_registry(self._skill_registry) |
| 351 | + else: |
| 352 | + self._skill_registry = None |
344 | 353 |
|
345 | 354 | # Initialize ExecutionAnalyzer (requires recording + skills) |
346 | 355 | if self.config.enable_recording and self._skill_registry: |
@@ -506,8 +515,13 @@ async def execute( |
506 | 515 | has_skills = False |
507 | 516 |
|
508 | 517 | # 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 | + ) |
511 | 525 |
|
512 | 526 | if has_skills: |
513 | 527 | logger.info(f"[Phase 1 — Skill] Executing with skill guidance (max {max_iterations} iterations)...") |
@@ -665,160 +679,9 @@ async def execute( |
665 | 679 |
|
666 | 680 | return final_result |
667 | 681 |
|
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. |
822 | 685 |
|
823 | 686 | async def _maybe_analyze_execution( |
824 | 687 | self, |
|
0 commit comments