Skip to content

Commit 1a37f0f

Browse files
DeepfreezechillBrian KrafftCopilot
authored
feat(4.6): Refactor OpenSpace into clean facade (#35)
* feat(4.6): refactor OpenSpace into clean facade Restructure initialize() from 190-line monolith into clean orchestration: - initialize(): 70-line high-level flow with numbered steps - _load_grounding_config(): config loading, backend scope resolution - _setup_skill_engine(): skill registry, store, analyzer, evolver Cleanup: - Remove dead imports (asyncio, uuid — now in ExecutionEngine) - Remove stale extraction comments (4.1/4.3 notes) - Update from_container docstring (remove Phase 4 TODO — it's done) Public API unchanged. All 1,356 tests pass. P4 tool_layer.py decomposition summary (788 → 477 lines, -39%): - 4.1: ToolRegistry (206 lines, PR #31) - 4.3: ExecutionEngine (459 lines, PR #32) - 4.4: RecordingService (70 lines, PR #33) - 4.5: LLMFactory (68 lines, PR #34) - 4.6: Facade cleanup (this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove unused Path import (review finding) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove stray main_tool_layer.py artifact 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 5537f7f commit 1a37f0f

1 file changed

Lines changed: 126 additions & 147 deletions

File tree

openspace/tool_layer.py

Lines changed: 126 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
from __future__ import annotations
22

3-
import asyncio
4-
import uuid
53
from dataclasses import dataclass, field
6-
from pathlib import Path
74
from typing import TYPE_CHECKING, Any, Dict, List, Optional
85

96
from openspace.agents import GroundingAgent
@@ -151,15 +148,7 @@ def from_container(
151148
container: AppContainer,
152149
config: Optional[OpenSpaceConfig] = None,
153150
) -> "OpenSpace":
154-
"""Create an OpenSpace instance backed by an AppContainer.
155-
156-
.. note::
157-
158-
Phase 1 only stores the container for property access.
159-
``initialize()`` still constructs its own services.
160-
Phase 4 will wire ``initialize()`` to resolve from the
161-
container, making injected services authoritative.
162-
"""
151+
"""Create an OpenSpace instance backed by an AppContainer."""
163152
return cls(config=config, container=container)
164153

165154
# ── Public property accessors (replace private field access) ──────
@@ -207,95 +196,30 @@ async def initialize(self) -> None:
207196
logger.info("Initializing OpenSpace...")
208197

209198
try:
199+
# 1. LLM clients
210200
self._llm_factory = LLMFactory(config=self.config)
211201
self._llm_client = self._llm_factory.create_main()
212202
logger.info(f"✓ LLM Client: {self.config.llm_model}")
213203

214-
# Load grounding config
215-
# If custom config is provided, merge it with default configs
216-
# load_config supports multiple files and deep merges them (later files override earlier ones)
217-
if self.config.grounding_config_path:
218-
from openspace.config.constants import CONFIG_GROUNDING, CONFIG_SECURITY
219-
from openspace.config.loader import CONFIG_DIR
220-
221-
# Load default configs + custom config (custom values will override defaults)
222-
grounding_config = load_config(
223-
CONFIG_DIR / CONFIG_GROUNDING, CONFIG_DIR / CONFIG_SECURITY, self.config.grounding_config_path
224-
)
225-
logger.info(f"Merged custom grounding config: {self.config.grounding_config_path}")
226-
else:
227-
# Load default configs only
228-
grounding_config = get_config()
229-
230-
# Optional: enable ClawWork productivity tools for fair benchmark comparison
231-
if getattr(self.config, "use_clawwork_productivity", False):
232-
shell_cfg = grounding_config.shell.model_copy(
233-
update={
234-
"use_clawwork_productivity": True,
235-
"working_dir": self.config.workspace_dir or grounding_config.shell.working_dir,
236-
}
237-
)
238-
grounding_config = grounding_config.model_copy(update={"shell": shell_cfg})
239-
logger.info("ClawWork productivity tools enabled (shell.working_dir used as sandbox root)")
240-
241-
# Resolve backend_scope early so we can skip initializing
242-
# providers that are not in scope (e.g. web when only shell is needed).
243-
agent_config = get_agent_config("GroundingAgent")
244-
_cli_max_iter = self.config.grounding_max_iterations
245-
_default_max_iter = OpenSpaceConfig().grounding_max_iterations # dataclass default (20)
246-
if agent_config:
247-
cfg_max_iter = agent_config.get("max_iterations", _default_max_iter)
248-
if _cli_max_iter != _default_max_iter:
249-
max_iterations = _cli_max_iter
250-
else:
251-
max_iterations = cfg_max_iter
252-
backend_scope = (
253-
self.config.backend_scope
254-
or agent_config.get("backend_scope")
255-
or ["gui", "shell", "mcp", "web", "system"]
256-
)
257-
visual_analysis_timeout = agent_config.get("visual_analysis_timeout", 30.0)
258-
self.config.grounding_max_iterations = max_iterations
259-
logger.info(
260-
f"Loaded GroundingAgent config from config_agents.json (max_iterations={max_iterations}, visual_analysis_timeout={visual_analysis_timeout}s)"
261-
)
262-
else:
263-
max_iterations = self.config.grounding_max_iterations
264-
backend_scope = self.config.backend_scope or ["gui", "shell", "mcp", "web", "system"]
265-
visual_analysis_timeout = 30.0
266-
logger.warning(f"config_agents.json not found, using default config (max_iterations={max_iterations})")
267-
268-
# Filter enabled_backends in grounding config to only those in scope,
269-
# so providers outside scope (e.g. web) are never registered/initialized.
270-
if grounding_config.enabled_backends:
271-
scope_set = set(backend_scope)
272-
filtered = [
273-
entry for entry in grounding_config.enabled_backends if entry.get("name", "").lower() in scope_set
274-
]
275-
if len(filtered) != len(grounding_config.enabled_backends):
276-
skipped = [
277-
entry.get("name")
278-
for entry in grounding_config.enabled_backends
279-
if entry.get("name", "").lower() not in scope_set
280-
]
281-
logger.info(f"Skipping backends not in scope: {skipped}")
282-
grounding_config = grounding_config.model_copy(update={"enabled_backends": filtered})
283-
204+
# 2. Grounding config + client
205+
grounding_config, backend_scope, max_iterations, visual_analysis_timeout = (
206+
self._load_grounding_config()
207+
)
284208
self._grounding_config = grounding_config
285209
self._grounding_client = GroundingClient(config=grounding_config)
286210
await self._grounding_client.initialize_all_providers()
287-
288211
backends = list(self._grounding_client.list_providers().keys())
289212
logger.info(f"✓ Grounding Client: {len(backends)} backends")
290213
logger.debug(f" Available backends: {[b.value for b in backends]}")
291214

215+
# 3. Recording
292216
if self.config.enable_recording:
293217
self._recording_service = RecordingService(config=self.config)
294218
self._recording_manager = self._recording_service.create(llm_client=self._llm_client)
295219
self._recording_service.wire(grounding_client=self._grounding_client)
296220
logger.info(f"✓ Recording enabled: {len(self._recording_manager.backends or [])} backends")
297221

298-
# Create separate LLM client for tool retrieval if configured
222+
# 4. Tool retrieval LLM + GroundingAgent
299223
tool_retrieval_llm = self._llm_factory.create_tool_retrieval()
300224
if tool_retrieval_llm:
301225
logger.info(f"✓ Tool retrieval LLM: {self.config.tool_retrieval_model}")
@@ -314,62 +238,10 @@ async def initialize(self) -> None:
314238
)
315239
logger.info(f"✓ GroundingAgent: {', '.join(backend_scope)}")
316240

317-
# Initialize SkillRegistry (settings from config_grounding.json → skills)
318-
if self._grounding_config and self._grounding_config.skills.enabled:
319-
self._tool_registry = ToolRegistry(
320-
config=self.config,
321-
grounding_config=self._grounding_config,
322-
llm_client=self._llm_client,
323-
)
324-
if self._tool_registry.discover():
325-
self._skill_registry = self._tool_registry.registry
326-
skills = self._skill_registry.list_skills()
327-
logger.info(f"✓ Skills: {len(skills)} discovered")
328-
self._grounding_agent.set_skill_registry(self._skill_registry)
329-
else:
330-
self._skill_registry = None
331-
332-
# Initialize ExecutionAnalyzer (requires recording + skills)
333-
if self.config.enable_recording and self._skill_registry:
334-
try:
335-
skill_store = SkillStore()
336-
self._skill_store = skill_store # Expose for MCP server reuse
337-
338-
# Sync filesystem skills → DB (creates initial records
339-
# for newly discovered skills so that analysis stats
340-
# can be recorded against them from the very first run).
341-
await skill_store.sync_from_registry(self._skill_registry.list_skills())
342-
343-
# Bridge: pass quality_manager so analysis can feed back
344-
# LLM-identified tool issues to the tool quality system.
345-
quality_mgr = self._grounding_client.quality_manager if self._grounding_client else None
346-
self._execution_analyzer = ExecutionAnalyzer(
347-
store=skill_store,
348-
llm_client=self._llm_client,
349-
model=self.config.execution_analyzer_model,
350-
skill_registry=self._skill_registry,
351-
quality_manager=quality_mgr,
352-
)
353-
logger.info("✓ Execution analysis enabled")
354-
355-
# Share store with GroundingAgent so retrieve_skill
356-
# can access quality metrics for LLM selection.
357-
self._grounding_agent._skill_store = skill_store
358-
359-
# Initialize SkillEvolver (reuses the same store & registry)
360-
# available_tools will be updated before each evolution cycle
361-
self._skill_evolver = SkillEvolver(
362-
store=skill_store,
363-
registry=self._skill_registry,
364-
llm_client=self._llm_client,
365-
model=self.config.skill_evolver_model,
366-
max_concurrent=self.config.evolution_max_concurrent,
367-
)
368-
logger.info(f"✓ Skill evolution enabled (concurrent={self.config.evolution_max_concurrent})")
369-
except Exception as e:
370-
logger.warning(f"Execution analyzer init failed (non-fatal): {e}")
241+
# 5. Skill engine (registry, store, analyzer, evolver)
242+
await self._setup_skill_engine()
371243

372-
# Create ExecutionEngine with all dependencies
244+
# 6. Execution engine (wires all deps)
373245
self._execution_engine = ExecutionEngine(
374246
config=self.config,
375247
grounding_agent=self._grounding_agent,
@@ -392,6 +264,121 @@ async def initialize(self) -> None:
392264
await self.cleanup()
393265
raise
394266

267+
def _load_grounding_config(self):
268+
"""Load and merge grounding config, resolve backend scope and agent params.
269+
270+
Returns (grounding_config, backend_scope, max_iterations, visual_analysis_timeout).
271+
"""
272+
if self.config.grounding_config_path:
273+
from openspace.config.constants import CONFIG_GROUNDING, CONFIG_SECURITY
274+
from openspace.config.loader import CONFIG_DIR
275+
276+
grounding_config = load_config(
277+
CONFIG_DIR / CONFIG_GROUNDING, CONFIG_DIR / CONFIG_SECURITY, self.config.grounding_config_path
278+
)
279+
logger.info(f"Merged custom grounding config: {self.config.grounding_config_path}")
280+
else:
281+
grounding_config = get_config()
282+
283+
if getattr(self.config, "use_clawwork_productivity", False):
284+
shell_cfg = grounding_config.shell.model_copy(
285+
update={
286+
"use_clawwork_productivity": True,
287+
"working_dir": self.config.workspace_dir or grounding_config.shell.working_dir,
288+
}
289+
)
290+
grounding_config = grounding_config.model_copy(update={"shell": shell_cfg})
291+
logger.info("ClawWork productivity tools enabled (shell.working_dir used as sandbox root)")
292+
293+
agent_config = get_agent_config("GroundingAgent")
294+
_cli_max_iter = self.config.grounding_max_iterations
295+
_default_max_iter = OpenSpaceConfig().grounding_max_iterations
296+
297+
if agent_config:
298+
cfg_max_iter = agent_config.get("max_iterations", _default_max_iter)
299+
max_iterations = _cli_max_iter if _cli_max_iter != _default_max_iter else cfg_max_iter
300+
backend_scope = (
301+
self.config.backend_scope
302+
or agent_config.get("backend_scope")
303+
or ["gui", "shell", "mcp", "web", "system"]
304+
)
305+
visual_analysis_timeout = agent_config.get("visual_analysis_timeout", 30.0)
306+
self.config.grounding_max_iterations = max_iterations
307+
logger.info(
308+
f"Loaded GroundingAgent config from config_agents.json "
309+
f"(max_iterations={max_iterations}, visual_analysis_timeout={visual_analysis_timeout}s)"
310+
)
311+
else:
312+
max_iterations = self.config.grounding_max_iterations
313+
backend_scope = self.config.backend_scope or ["gui", "shell", "mcp", "web", "system"]
314+
visual_analysis_timeout = 30.0
315+
logger.warning(f"config_agents.json not found, using default config (max_iterations={max_iterations})")
316+
317+
if grounding_config.enabled_backends:
318+
scope_set = set(backend_scope)
319+
filtered = [
320+
entry for entry in grounding_config.enabled_backends if entry.get("name", "").lower() in scope_set
321+
]
322+
if len(filtered) != len(grounding_config.enabled_backends):
323+
skipped = [
324+
entry.get("name")
325+
for entry in grounding_config.enabled_backends
326+
if entry.get("name", "").lower() not in scope_set
327+
]
328+
logger.info(f"Skipping backends not in scope: {skipped}")
329+
grounding_config = grounding_config.model_copy(update={"enabled_backends": filtered})
330+
331+
return grounding_config, backend_scope, max_iterations, visual_analysis_timeout
332+
333+
async def _setup_skill_engine(self) -> None:
334+
"""Initialize skill registry, store, analyzer, and evolver."""
335+
if not (self._grounding_config and self._grounding_config.skills.enabled):
336+
return
337+
338+
self._tool_registry = ToolRegistry(
339+
config=self.config,
340+
grounding_config=self._grounding_config,
341+
llm_client=self._llm_client,
342+
)
343+
if self._tool_registry.discover():
344+
self._skill_registry = self._tool_registry.registry
345+
skills = self._skill_registry.list_skills()
346+
logger.info(f"✓ Skills: {len(skills)} discovered")
347+
self._grounding_agent.set_skill_registry(self._skill_registry)
348+
else:
349+
self._skill_registry = None
350+
351+
if not (self.config.enable_recording and self._skill_registry):
352+
return
353+
354+
try:
355+
skill_store = SkillStore()
356+
self._skill_store = skill_store
357+
await skill_store.sync_from_registry(self._skill_registry.list_skills())
358+
359+
quality_mgr = self._grounding_client.quality_manager if self._grounding_client else None
360+
self._execution_analyzer = ExecutionAnalyzer(
361+
store=skill_store,
362+
llm_client=self._llm_client,
363+
model=self.config.execution_analyzer_model,
364+
skill_registry=self._skill_registry,
365+
quality_manager=quality_mgr,
366+
)
367+
logger.info("✓ Execution analysis enabled")
368+
369+
self._grounding_agent._skill_store = skill_store
370+
371+
self._skill_evolver = SkillEvolver(
372+
store=skill_store,
373+
registry=self._skill_registry,
374+
llm_client=self._llm_client,
375+
model=self.config.skill_evolver_model,
376+
max_concurrent=self.config.evolution_max_concurrent,
377+
)
378+
logger.info(f"✓ Skill evolution enabled (concurrent={self.config.evolution_max_concurrent})")
379+
except Exception as e:
380+
logger.warning(f"Execution analyzer init failed (non-fatal): {e}")
381+
395382
async def execute(
396383
self,
397384
task: str,
@@ -413,14 +400,6 @@ async def execute(
413400
task_id=task_id,
414401
)
415402

416-
# NOTE: _init_skill_registry, _select_and_inject_skills, and
417-
# _get_skill_selection_llm have been extracted to ToolRegistry
418-
# (openspace/tool_registry.py) in Epic 4.1.
419-
#
420-
# execute(), _maybe_analyze_execution(), and _maybe_evolve_quality()
421-
# have been extracted to ExecutionEngine
422-
# (openspace/execution_engine.py) in Epic 4.3.
423-
424403
async def cleanup(self) -> None:
425404
"""
426405
Close all sessions and release resources.

0 commit comments

Comments
 (0)