fix: CAPTURED skills write to correct host agent skill dir

This commit is contained in:
xlrrrr 2026-04-10 20:57:17 +08:00
parent 8e6d49df74
commit 79a98abda7
3 changed files with 106 additions and 10 deletions

View file

@ -578,6 +578,20 @@ async def execute_task(
if skill_dirs:
await _auto_register_skill_dirs(skill_dirs)
# Determine where CAPTURED skills should be written.
# Prefer the explicit skill_dirs parameter (= calling host agent's dir),
# then fall back to the first env-based host skill dir.
capture_skill_dir: str | None = None
if skill_dirs:
capture_skill_dir = skill_dirs[0]
elif host_skill_dirs_raw:
first_env = next(
(d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()),
None,
)
if first_env:
capture_skill_dir = first_env
# Cloud search + import (if requested)
imported_skills: List[Dict[str, Any]] = []
if search_scope == "all":
@ -588,6 +602,7 @@ async def execute_task(
task=task,
workspace_dir=workspace_dir,
max_iterations=max_iterations,
capture_skill_dir=capture_skill_dir,
)
# Write .upload_meta.json for each evolved skill

View file

@ -145,6 +145,11 @@ class EvolutionContext:
# Available tools for agent loop (read_file, web_search, shell, MCP, etc.)
available_tools: List["BaseTool"] = field(default_factory=list)
# For CAPTURED: preferred directory to write the new skill.
# Set from the calling host agent's skill directory so captured skills
# are written back to the correct host, not always to _skill_dirs[0].
capture_dir: Optional[Path] = None
class SkillEvolver:
"""Execute skill evolution actions.
@ -259,13 +264,20 @@ class SkillEvolver:
# Trigger 1: post-analysis
async def process_analysis(
self, analysis: ExecutionAnalysis,
self,
analysis: ExecutionAnalysis,
capture_dir: Optional[Path] = None,
) -> List[SkillRecord]:
"""Process all evolution suggestions from a completed analysis.
Called immediately after ``ExecutionAnalyzer.analyze_execution()``.
Each suggestion becomes one evolution action, executed in parallel
(throttled by semaphore).
Args:
analysis: The completed execution analysis.
capture_dir: Preferred directory for CAPTURED skills (host agent's
skill dir). Falls back to ``registry._skill_dirs[0]`` when None.
"""
if not analysis.candidate_for_evolution:
return []
@ -273,7 +285,9 @@ class SkillEvolver:
# Build contexts first (cheap, no LLM calls)
contexts: List[EvolutionContext] = []
for suggestion in analysis.evolution_suggestions:
ctx = self._build_context_from_analysis(analysis, suggestion)
ctx = self._build_context_from_analysis(
analysis, suggestion, capture_dir=capture_dir,
)
if ctx is not None:
contexts.append(ctx)
@ -948,13 +962,23 @@ class SkillEvolver:
new_content = _set_frontmatter_field(new_content, "name", new_name)
# Create new skill directory via create_skill (handles multi-file FULL)
skill_dirs = self._registry._skill_dirs
if not skill_dirs:
logger.warning("CAPTURED: no skill directories configured")
return None
# Priority chain for choosing the target skill root:
# 1. ctx.capture_dir — explicitly set from host agent's skill_dirs param
# 2. Infer from analysis — if this task used a skill from dir B,
# captured skills belong alongside it (same host agent context)
# 3. registry._skill_dirs[0] — ultimate fallback
base_dir: Optional[Path] = None
if ctx.capture_dir and ctx.capture_dir.is_dir():
base_dir = ctx.capture_dir
else:
base_dir = self._infer_capture_dir_from_analysis(ctx)
# Directory name always matches the skill name
base_dir = skill_dirs[0] # Primary user skill directory
if base_dir is None:
skill_dirs = self._registry._skill_dirs
if not skill_dirs:
logger.warning("CAPTURED: no skill directories configured")
return None
base_dir = skill_dirs[0]
target_dir = base_dir / new_name
if target_dir.exists():
new_name = f"{new_name}-{uuid.uuid4().hex[:6]}"
@ -1016,6 +1040,45 @@ class SkillEvolver:
logger.info(f"CAPTURED: {new_name} [{new_id}]")
return new_record
def _infer_capture_dir_from_analysis(
self, ctx: EvolutionContext,
) -> Optional[Path]:
"""Infer the best skill root for a CAPTURED skill from analysis context.
When ``capture_dir`` is not explicitly set (no ``skill_dirs`` param
from the host agent), we look at which skills were used during the
task that triggered the capture. If a used skill lives under one
of the registered skill roots, that root is a reasonable home for
the new captured skill (same host agent context).
"""
if not ctx.recent_analyses:
return None
registry_roots = self._registry._skill_dirs
if not registry_roots:
return None
for analysis in ctx.recent_analyses:
for judgment in analysis.skill_judgments:
if not judgment.skill_applied:
continue
rec = self._store.load_record(judgment.skill_id)
if not rec or not rec.path:
continue
skill_path = Path(rec.path).parent # e.g. /A/foo/
for root in registry_roots:
try:
skill_path.relative_to(root)
logger.debug(
"CAPTURED: inferred capture dir %s from "
"applied skill %s", root, judgment.skill_id,
)
return root
except ValueError:
continue
return None
async def _run_evolution_loop(
self,
prompt: str,
@ -1362,13 +1425,15 @@ class SkillEvolver:
self,
analysis: ExecutionAnalysis,
suggestion: EvolutionSuggestion,
capture_dir: Optional[Path] = None,
) -> Optional[EvolutionContext]:
"""Build EvolutionContext from a single analysis suggestion.
Loads all target skills referenced by ``suggestion.target_skill_ids``.
For FIX: exactly 1 parent required.
For DERIVED: 1+ parents (multi-parent = merge).
For CAPTURED: parents list is empty.
For CAPTURED: parents list is empty; ``capture_dir`` controls where
the new skill is written (defaults to registry's first skill root).
"""
records: List[SkillRecord] = []
contents: List[str] = []
@ -1412,6 +1477,7 @@ class SkillEvolver:
source_task_id=analysis.task_id,
recent_analyses=[analysis],
available_tools=self._available_tools,
capture_dir=capture_dir,
)
def _load_skill_content(self, record: SkillRecord) -> str:

View file

@ -306,6 +306,7 @@ class OpenSpace:
workspace_dir: Optional[str] = None,
max_iterations: Optional[int] = None,
task_id: Optional[str] = None,
capture_skill_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute a task with OpenSpace.
@ -321,6 +322,9 @@ class OpenSpace:
task_id: External task ID for recording/logging. If None, generates a random one.
This allows external callers (e.g., OSWorld) to specify their own task ID
so recordings can be easily matched with benchmark results.
capture_skill_dir: Preferred directory for CAPTURED skills. In multi-host-agent
scenarios, this should be the calling host agent's skill directory so
newly captured skills are written to the correct location.
"""
if not self._initialized:
raise RuntimeError(
@ -351,6 +355,7 @@ class OpenSpace:
self._running = True
self._task_done.clear()
self._last_evolved_skills = [] # Reset per-execution tracking
self._capture_skill_dir = capture_skill_dir
start_time = asyncio.get_event_loop().time()
# Use external task_id if provided, otherwise generate one
if task_id is None:
@ -806,7 +811,17 @@ class OpenSpace:
for s in analysis.evolution_suggestions
)
logger.info(f"[Skill Evolution] Suggestions: {evo_summary}")
evolved_records = await self._skill_evolver.process_analysis(analysis)
capture_dir = None
if getattr(self, "_capture_skill_dir", None):
from pathlib import Path as _P
_cd = _P(self._capture_skill_dir)
if _cd.is_dir():
capture_dir = _cd
evolved_records = await self._skill_evolver.process_analysis(
analysis, capture_dir=capture_dir,
)
# Track evolved skills for the caller
for rec in evolved_records: