mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Previously `core/config.py` defined `DEFAULT_CONFIG_DIR = Path.home() /
".openjarvis"` as a by-value module constant imported into ~45 modules, and 34
modules hardcoded `Path.home() / ".openjarvis"` directly. The installer honored
`OPENJARVIS_HOME` but the Python runtime ignored it, producing a split-brain
layout (some modules honored the override, the core config dir did not). Eval
dataset caches also scattered into `~/.cache/<benchmark>`.
This introduces a single env-aware resolver in `openjarvis/core/paths.py` and
routes every state/config/cache path through it. OpenJarvis now keeps ALL of
its state under ONE root, resolved in priority order:
1. $OPENJARVIS_HOME
2. $XDG_DATA_HOME/openjarvis (single nested dir, when XDG_DATA_HOME is set)
3. ~/.openjarvis (default — unchanged, so existing installs are
untouched and no data migration is required)
Implementation:
- New `core/paths.py`: get_config_dir / get_config_path / get_data_dir /
get_cache_dir, with a source-tree rejection guard (fails loudly per
REVIEW.md if the root resolves inside the repo).
- `core/config.py`: DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_PATH are now resolved
via the env-aware resolver at import (real attributes, so existing
monkeypatch.setattr-based tests keep working). All dataclass field defaults
that pointed at ~/.openjarvis converted to default_factory so they honor the
override at instantiation.
- Routed all 34 hardcoders plus several string-literal escapees the original
audit missed: prompt_loader / description_loader (were OPENJARVIS_HOME-only,
no XDG), swebench_harness cache, tools/{memory,skill,user_profile}_manage
defaults, server trace.db fallbacks, doctor_cmd hints.
- spec_search storage/paths now delegates to the unified resolver (gains XDG);
its ConfigurationError is aliased to the core one.
- Eval dataset caches moved from ~/.cache/<name> to <root>/cache/<name>
(~/.cache/huggingface left alone — it is HF's own cache).
- Docs + installer comment + `jarvis config path` to show resolved dirs.
Read-only macOS connectors and OS service files (LaunchAgents/systemd) are
intentionally left untouched.
Fixes #462
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""SkillOptimizer — per-skill DSPy/GEPA optimization wrapper (Plan 2A).
|
|
|
|
Buckets traces by skill name, runs the underlying optimizer on each skill's
|
|
bucket, and writes the result as a sidecar overlay file in
|
|
``~/.openjarvis/learning/skills/<skill-name>/optimized.toml``.
|
|
|
|
The actual DSPy/GEPA invocation is done in ``_run_dspy`` / ``_run_gepa``,
|
|
which are isolated for easy mocking in tests. In Plan 2A these are
|
|
deliberately minimal — they call the existing optimizer modules and extract
|
|
the description + few-shot examples. Plan 2B will measure the impact via
|
|
benchmarks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from openjarvis.core.paths import get_config_dir
|
|
from openjarvis.core.types import Trace, TraceStep
|
|
from openjarvis.skills.manager import SkillManager
|
|
from openjarvis.skills.overlay import SkillOverlay, write_overlay
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SkillOptimizationResult:
|
|
"""Result of optimizing a single skill."""
|
|
|
|
skill_name: str
|
|
status: str # "optimized" | "skipped" | "error"
|
|
trace_count: int = 0
|
|
overlay_path: Optional[Path] = None
|
|
error: str = ""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _OptimizerOutput:
|
|
"""Internal: extracted output from the underlying DSPy/GEPA optimizer."""
|
|
|
|
description: str = ""
|
|
few_shot: List[Dict[str, str]] = field(default_factory=list)
|
|
|
|
|
|
class SkillOptimizer:
|
|
"""Per-skill optimization wrapper around DSPyAgentOptimizer / GEPA.
|
|
|
|
Parameters
|
|
----------
|
|
min_traces_per_skill:
|
|
Minimum trace count for a skill to be eligible for optimization.
|
|
optimizer:
|
|
``"dspy"`` or ``"gepa"``. Determines which underlying optimizer is
|
|
called inside ``_run_dspy`` / ``_run_gepa``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
min_traces_per_skill: int = 20,
|
|
optimizer: str = "dspy",
|
|
) -> None:
|
|
self._min_traces = min_traces_per_skill
|
|
self._optimizer = optimizer
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def optimize(
|
|
self,
|
|
trace_store: Any,
|
|
skill_manager: SkillManager,
|
|
*,
|
|
overlay_dir: Optional[Path] = None,
|
|
) -> Dict[str, SkillOptimizationResult]:
|
|
"""Run the per-skill optimization loop.
|
|
|
|
Returns a dict mapping skill name to result. Writes overlay TOML
|
|
files for each skill that produced output.
|
|
"""
|
|
if overlay_dir is None:
|
|
# Try config first; fall back to the default tree.
|
|
try:
|
|
from openjarvis.core.config import load_config
|
|
|
|
cfg = load_config()
|
|
cfg_dir = getattr(
|
|
getattr(cfg.learning, "skills", None),
|
|
"overlay_dir",
|
|
None,
|
|
)
|
|
if cfg_dir:
|
|
overlay_dir = Path(cfg_dir).expanduser()
|
|
except Exception:
|
|
pass
|
|
if overlay_dir is None:
|
|
overlay_dir = Path(
|
|
str(get_config_dir() / "learning" / "skills")
|
|
).expanduser()
|
|
overlay_dir = Path(overlay_dir).expanduser()
|
|
|
|
traces = trace_store.list_traces(limit=10000)
|
|
buckets = self._bucket_traces_by_skill(traces)
|
|
|
|
results: Dict[str, SkillOptimizationResult] = {}
|
|
for skill_name, skill_traces in buckets.items():
|
|
if len(skill_traces) < self._min_traces:
|
|
results[skill_name] = SkillOptimizationResult(
|
|
skill_name=skill_name,
|
|
status="skipped",
|
|
trace_count=len(skill_traces),
|
|
)
|
|
continue
|
|
|
|
try:
|
|
if self._optimizer == "gepa":
|
|
output = self._run_gepa(skill_name, skill_traces)
|
|
else:
|
|
output = self._run_dspy(skill_name, skill_traces)
|
|
except Exception as exc:
|
|
LOGGER.warning("Skill optimizer failed for '%s': %s", skill_name, exc)
|
|
results[skill_name] = SkillOptimizationResult(
|
|
skill_name=skill_name,
|
|
status="error",
|
|
trace_count=len(skill_traces),
|
|
error=str(exc),
|
|
)
|
|
continue
|
|
|
|
overlay = SkillOverlay(
|
|
skill_name=skill_name,
|
|
optimizer=self._optimizer,
|
|
optimized_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
trace_count=len(skill_traces),
|
|
description=output.description,
|
|
few_shot=list(output.few_shot),
|
|
)
|
|
path = write_overlay(overlay, overlay_dir)
|
|
results[skill_name] = SkillOptimizationResult(
|
|
skill_name=skill_name,
|
|
status="optimized",
|
|
trace_count=len(skill_traces),
|
|
overlay_path=path,
|
|
)
|
|
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bucketing
|
|
# ------------------------------------------------------------------
|
|
|
|
def _bucket_traces_by_skill(self, traces: List[Trace]) -> Dict[str, List[Trace]]:
|
|
"""Group traces by the skill names they invoked.
|
|
|
|
A trace is added to a skill's bucket once for each unique skill it
|
|
invoked. Traces with no skill-tagged tool calls are dropped entirely.
|
|
"""
|
|
buckets: Dict[str, List[Trace]] = {}
|
|
for trace in traces:
|
|
skill_names = self._extract_skill_names(trace)
|
|
for name in skill_names:
|
|
buckets.setdefault(name, []).append(trace)
|
|
return buckets
|
|
|
|
@staticmethod
|
|
def _extract_skill_names(trace: Trace) -> List[str]:
|
|
"""Return the unique skill names invoked in *trace*'s tool steps."""
|
|
seen: List[str] = []
|
|
steps = getattr(trace, "steps", []) or []
|
|
for step in steps:
|
|
metadata: Dict[str, Any] = {}
|
|
if isinstance(step, dict):
|
|
metadata = step.get("metadata", {}) or {}
|
|
elif isinstance(step, TraceStep):
|
|
metadata = step.metadata or {}
|
|
else:
|
|
metadata = getattr(step, "metadata", {}) or {}
|
|
skill_name = metadata.get("skill")
|
|
if skill_name and skill_name not in seen:
|
|
seen.append(skill_name)
|
|
return seen
|
|
|
|
# ------------------------------------------------------------------
|
|
# Underlying optimizers (mockable in tests)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _run_dspy(
|
|
self,
|
|
skill_name: str,
|
|
skill_traces: List[Trace],
|
|
) -> _OptimizerOutput:
|
|
"""Run DSPyAgentOptimizer on the bucket and extract description + few-shot.
|
|
|
|
In Plan 2A this is intentionally simple — it pulls the few highest-
|
|
feedback traces as candidate few-shot examples and uses the agent
|
|
optimizer's output `system_prompt` as the new skill description if
|
|
non-empty. Plan 2B will measure and refine.
|
|
"""
|
|
from openjarvis.core.config import DSPyOptimizerConfig
|
|
from openjarvis.learning.agents.dspy_optimizer import DSPyAgentOptimizer
|
|
|
|
cfg = DSPyOptimizerConfig(
|
|
min_traces=max(1, self._min_traces),
|
|
max_bootstrapped_demos=4,
|
|
max_labeled_demos=4,
|
|
)
|
|
optimizer = DSPyAgentOptimizer(cfg)
|
|
|
|
# Build a tiny in-memory trace store shim for the underlying optimizer
|
|
class _BucketStore:
|
|
def __init__(self, _traces):
|
|
self._traces = _traces
|
|
|
|
def list_traces(self, *, limit=100, **_kwargs):
|
|
return list(self._traces[:limit])
|
|
|
|
try:
|
|
updates = optimizer.optimize(_BucketStore(skill_traces)) or {}
|
|
except Exception as exc:
|
|
LOGGER.warning(
|
|
"DSPyAgentOptimizer raised for '%s' (using empty output): %s",
|
|
skill_name,
|
|
exc,
|
|
)
|
|
updates = {}
|
|
|
|
description = str(updates.get("system_prompt", "")) if updates else ""
|
|
few_shot_raw = updates.get("few_shot_examples", []) if updates else []
|
|
few_shot: List[Dict[str, str]] = []
|
|
for item in few_shot_raw or []:
|
|
if isinstance(item, dict):
|
|
few_shot.append(
|
|
{
|
|
"input": str(item.get("input", "")),
|
|
"output": str(item.get("output", "")),
|
|
}
|
|
)
|
|
|
|
# Fallback: if DSPy produced nothing, derive few-shot from top traces
|
|
if not few_shot:
|
|
top = sorted(
|
|
skill_traces,
|
|
key=lambda t: t.feedback if t.feedback is not None else 0.0,
|
|
reverse=True,
|
|
)[:4]
|
|
for tr in top:
|
|
if tr.query and tr.result:
|
|
few_shot.append({"input": tr.query, "output": tr.result})
|
|
|
|
return _OptimizerOutput(description=description, few_shot=few_shot)
|
|
|
|
def _run_gepa(
|
|
self,
|
|
skill_name: str,
|
|
skill_traces: List[Trace],
|
|
) -> _OptimizerOutput:
|
|
"""Run GEPAAgentOptimizer on the bucket. Same shape as _run_dspy."""
|
|
from openjarvis.core.config import GEPAOptimizerConfig
|
|
from openjarvis.learning.agents.gepa_optimizer import GEPAAgentOptimizer
|
|
|
|
cfg = GEPAOptimizerConfig(min_traces=max(1, self._min_traces))
|
|
optimizer = GEPAAgentOptimizer(cfg)
|
|
|
|
class _BucketStore:
|
|
def __init__(self, _traces):
|
|
self._traces = _traces
|
|
|
|
def list_traces(self, *, limit=100, **_kwargs):
|
|
return list(self._traces[:limit])
|
|
|
|
try:
|
|
updates = optimizer.optimize(_BucketStore(skill_traces)) or {}
|
|
except Exception as exc:
|
|
LOGGER.warning(
|
|
"GEPAAgentOptimizer raised for '%s' (using empty output): %s",
|
|
skill_name,
|
|
exc,
|
|
)
|
|
updates = {}
|
|
|
|
description = str(updates.get("system_prompt", "")) if updates else ""
|
|
|
|
# Parse GEPA's few_shot_examples output (matches DSPy path semantics)
|
|
few_shot_raw = updates.get("few_shot_examples", []) if updates else []
|
|
few_shot: List[Dict[str, str]] = []
|
|
for item in few_shot_raw or []:
|
|
if isinstance(item, dict):
|
|
few_shot.append(
|
|
{
|
|
"input": str(item.get("input", "")),
|
|
"output": str(item.get("output", "")),
|
|
}
|
|
)
|
|
|
|
# Fallback: if GEPA produced nothing, derive few-shot from top traces
|
|
if not few_shot:
|
|
top = sorted(
|
|
skill_traces,
|
|
key=lambda t: t.feedback if t.feedback is not None else 0.0,
|
|
reverse=True,
|
|
)[:4]
|
|
for tr in top:
|
|
if tr.query and tr.result:
|
|
few_shot.append({"input": tr.query, "output": tr.result})
|
|
return _OptimizerOutput(description=description, few_shot=few_shot)
|
|
|
|
|
|
__all__ = ["SkillOptimizer", "SkillOptimizationResult"]
|