Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ All notable changes to SkillOpt are documented here. This project adheres to
## [Unreleased]

### Added
- **SkillOpt-Sleep opt-in `llm_dream`**: paraphrase-only dream variants from
the optimizer model, with task-aware semantic verification and deterministic
template fallback on parse or fidelity failure. Generation and verification
stay on the optimizer side of dual backends; target replay is untouched.
Default template dreams stay byte-identical and generated variants remain
train-only (thanks @bogdanbaciu21).
- **SkillOpt-Sleep multi-skill fan-out and reviewed subset adoption**: each
hinted skill is consolidated from its own pinned live baseline, staged as an
independent proposal with per-skill gate evidence, and promoted only through
Expand Down
40 changes: 40 additions & 0 deletions docs/sleep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,46 @@ correctness signal; the validation gate still governs what ships.
| `dream_rollouts` | `1` | Run each task K times → learn from the good-vs-bad contrast (contrastive reflection). |
| `recall_k` | `0` | Associative recall — pull the K most-similar past tasks (from a persisted archive) into tonight's dream. |
| `dream_factor` | `0` | Add N lightweight synthetic variants of each task. |
| `llm_dream` | `false` | Opt-in optimizer-side paraphrase generator and task-aware semantic verifier. Templates stay the default and are used on any generation, parse, deterministic, or semantic-fidelity failure. v1 is paraphrase-only: parent `reference`/`judge` are copied unchanged, generated tasks stay train-only, and target replay is untouched. |

`llm_dream` accepts only the JSON/YAML boolean `true`; string and numeric values
fail closed to `false`. Coding-agent optimizers may generate dreams only when
their ordinary call path has a verified no-tools boundary. Pi disables tools
and ambient extensions; OpenCode resolves its agent configuration and refuses
generation unless the enabled-tool set is empty. Claude generation additionally
requires API-key authentication so `--bare` can disable hooks and plugins;
Claude subscription authentication therefore falls back to templates. Provider
API backends do not expose local tools. Codex, Copilot, Cursor, handoff, and any
other unverified coding-agent path also fail closed to deterministic template
dreams instead of exposing harvested task text to an agent tool loop.

The deterministic replay fixture in `tests/test_llm_dream.py` drives the full
cycle through config resolution, optimizer-only generation, validation gating,
evidence logging, and staging. It exercises one accepted and one rejected
candidate (50% acceptance / 50% fallback), accounts optimizer generation usage
while confirming zero target-generation calls, and records an exact held-out
improvement from `0.0` to `1.0`, matching the template control. It is
deterministic CI evidence, not a claim about any live provider's semantic
quality or pricing.

Every LLM-enabled aggregate or per-skill consolidation also emits one
`llm_dream_summary` evidence row with source/requested/accepted/fallback counts,
fallback reasons, and the optimizer-token delta for generation plus semantic
verification. An opt-in live full-cycle check exercises the same contract
through a factory-built OpenCode optimizer while keeping a mock target isolated:

```bash
SKILLOPT_TEST_REAL_OPENCODE=1 \
SKILLOPT_SLEEP_OPENCODE_MODEL=provider/model \
python -m pytest \
tests/test_backend_opencode_live.py::test_real_opencode_optimizer_dream_cycle -q
```

That test is excluded from ordinary CI because it uses the caller's installed
OpenCode account and may incur provider charges. When enabled, it requires at
least one accepted generation, internally consistent acceptance/fallback
accounting, positive optimizer usage, persisted evidence/staging, target replay,
and validation non-regression.

## Results

Expand Down
2 changes: 1 addition & 1 deletion plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ python -m skillopt_sleep run --project "$(pwd)" \

The JSON/YAML config under `~/.skillopt-sleep/` supports additional engine keys,
including `gate_mode`, `gate_metric`, `gate_no_regression`, `dream_rollouts`,
`dream_factor`, `recall_k`, `evolve_memory`, and `evolve_skill`. These are config
`dream_factor`, `llm_dream`, `recall_k`, `evolve_memory`, and `evolve_skill`. These are config
keys, not aliases for the unsupported CLI flags listed above. Shipping defaults
are conservative: `gate_mode="on"`, `gate_no_regression=false`,
`dream_rollouts=1`, `dream_factor=0`, and `recall_k=0`.
Expand Down
26 changes: 21 additions & 5 deletions plugins/openclaw/skillopt_sleep_openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@
import json
import os
import re
import subprocess
from typing import Any, Dict, List, Optional, Tuple
from typing import Dict, List, Tuple

from skillopt_sleep.backend import Backend, _normalize, exact_score
from skillopt_sleep.backend import Backend, exact_score
from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord


# ── DeepSeek + Ollama OpenAI-compatible API client (curl-based, no extra deps) ──


Expand Down Expand Up @@ -108,8 +106,26 @@ def __init__(
def tokens_used(self) -> int:
return self._tokens

def generate(self, prompt: str, *, max_tokens: int = 1024) -> str:
"""Generate optimizer material without entering task replay."""
out = _chat(
[{"role": "user", "content": prompt}],
model=self._model,
temperature=0.2,
max_tokens=max_tokens,
)
self._tokens += len(prompt) // 4 + len(out) // 4
return out

# ── 1. attempt: produce a response given the task + skill + memory ──
def attempt(self, task: TaskRecord, skill: str, memory: str) -> str:
def attempt(
self,
task: TaskRecord,
skill: str,
memory: str,
sample_id: int = 0,
) -> str:
del sample_id # OpenClaw calls are uncached; every rollout is independent.
sys = (
"You are an OpenClaw agent (Kobe ecosystem). Use the skill and memory below to complete the task. "
"If the task asks for a structured output, follow the rubric exactly. "
Expand Down
2 changes: 1 addition & 1 deletion skillopt_sleep/adapters/superpowers.py
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ def _run_scenario(
if p.is_symlink():
raise ValueError(f"Refusing symlinked overlay path: {p}")
skill_dir.mkdir(parents=True, exist_ok=True)
if not skill_dir.resolve().is_relative_to(workspace):
if not skill_dir.resolve().is_relative_to(workspace.resolve()):
raise ValueError(f"Skill path {skill_dir} escapes workspace {workspace}")
shutil.copy2(skill_overlay, skill_dest, follow_symlinks=False)

Expand Down
124 changes: 112 additions & 12 deletions skillopt_sleep/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple

from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord

Expand Down Expand Up @@ -60,6 +60,10 @@ def attempt(self, task: TaskRecord, skill: str, memory: str,
sample_id: int = 0) -> str:
raise NotImplementedError

def generate(self, prompt: str, *, max_tokens: int = 1024) -> str:
"""Run optimizer-side text generation, never target task execution."""
raise NotImplementedError

def attempt_with_tools(
self, task: TaskRecord, skill: str, memory: str, tools: List[str]
) -> Tuple[str, List[str]]:
Expand Down Expand Up @@ -324,6 +328,11 @@ class CliBackend(Backend):
"""

name = "cli"
# Subclasses must opt in only after their ordinary call path has a verified
# no-tools boundary. Dream prompts contain harvested task text, so routing
# them through a coding-agent shell merely because it can return text would
# turn paraphrasing into an unintended local execution surface.
generation_tools_disabled = False

def __init__(self, model: str = "", timeout: int = 180) -> None:
self.model = model
Expand All @@ -337,27 +346,62 @@ def __init__(self, model: str = "", timeout: int = 180) -> None:
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
raise NotImplementedError

def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str:
def _cached_call(
self,
key: str,
prompt: str,
*,
max_tokens: int = 1024,
call_fn: Optional[Callable[..., str]] = None,
) -> str:
kind = key.split(":", 1)[0]
ev = getattr(self, "evidence", None)
event_stage = "dream" if kind == "generate" else "replay"
event_phase = (
"dream" if kind == "generate" else getattr(self, "evidence_phase", "")
)
if key in self._cache:
# cache hits log key-only (the full text is on the original miss event)
if ev is not None:
ev.log("replay", "model_call", kind=kind, cache_hit=True, key=key,
phase=getattr(self, "evidence_phase", ""), backend=self.name,
ev.log(event_stage, "model_call", kind=kind, cache_hit=True, key=key,
phase=event_phase, backend=self.name,
model=self.model)
return self._cache[key]
out = self._call(prompt, max_tokens=max_tokens)
out = (call_fn or self._call)(prompt, max_tokens=max_tokens)
self._tokens += len(prompt) // 4 + len(out) // 4
self._cache[key] = out
if ev is not None:
ev.log("replay", "model_call", kind=kind, cache_hit=False, key=key,
phase=getattr(self, "evidence_phase", ""), backend=self.name,
ev.log(event_stage, "model_call", kind=kind, cache_hit=False, key=key,
phase=event_phase, backend=self.name,
model=self.model, prompt=prompt, response=out,
error=getattr(self, "last_call_error", "") or "")
return out

# operations -----------------------------------------------------------
def _generation_boundary_verified(self) -> bool:
"""Return whether this instance's ordinary call path disables tools."""
return self.generation_tools_disabled

def _generation_call(self, prompt: str, *, max_tokens: int = 1024) -> str:
"""Call through the backend's verified generation-only boundary."""
return self._call(prompt, max_tokens=max_tokens)

def generate(self, prompt: str, *, max_tokens: int = 1024) -> str:
"""Generate optimizer material through the backend's native call path."""
if not self._generation_boundary_verified():
self.last_call_error = (
f"{self.name} optimizer generation is disabled because its "
"no-tools boundary has not been verified"
)
raise RuntimeError(self.last_call_error)
key = "generate:" + skill_hash(prompt)
return self._cached_call(
key,
prompt,
max_tokens=max_tokens,
call_fn=self._generation_call,
)

def attempt(self, task: TaskRecord, skill: str, memory: str,
sample_id: int = 0) -> str:
# sample_id distinguishes repeated rollouts of the SAME (task, skill,
Expand Down Expand Up @@ -572,6 +616,7 @@ class PiCliBackend(CliBackend):
"""

name = "pi"
generation_tools_disabled = True

def __init__(self, model: str = "", pi_path: str = "pi", timeout: int = 180) -> None:
super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_PI_MODEL", ""),
Expand Down Expand Up @@ -602,13 +647,22 @@ def _set_call_error(self, message: object) -> None:
"Pi CLI call failed: %s", self.last_call_error
)

def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str:
def _cached_call(
self,
key: str,
prompt: str,
*,
max_tokens: int = 1024,
call_fn: Optional[Callable[..., str]] = None,
) -> str:
"""Do not make a transient Pi failure sticky in the response cache."""
if key in self._cache:
# A cached success must not expose an unrelated previous failure
# through diagnostics/evidence attached to this call.
self.last_call_error = ""
out = super()._cached_call(key, prompt, max_tokens=max_tokens)
out = super()._cached_call(
key, prompt, max_tokens=max_tokens, call_fn=call_fn
)
if not out:
self._cache.pop(key, None)
return out
Expand Down Expand Up @@ -703,6 +757,10 @@ class ClaudeCliBackend(CliBackend):
"""Drives the authenticated `claude` CLI: claude -p --output-format text."""

name = "claude"
# Claude's `--bare` mode disables hooks/plugins as well as tools, but it is
# compatible only with API-key auth. Subscription auth therefore cannot
# meet the stronger boundary required for harvested dream inputs.
generation_tools_disabled = False

def __init__(self, model: str = "", claude_path: str = "claude", timeout: int = 180) -> None:
super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_CLAUDE_MODEL", "") or "sonnet",
Expand Down Expand Up @@ -747,7 +805,33 @@ def _detect_cli_error(self, stdout: str, stderr: str) -> None:
self.last_call_error = combined[:500]
return

def _generation_boundary_verified(self) -> bool:
return bool(os.environ.get("ANTHROPIC_API_KEY"))

def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
return self._call_claude(
prompt,
max_tokens=max_tokens,
bare=bool(os.environ.get("ANTHROPIC_API_KEY")),
)

def _generation_call(self, prompt: str, *, max_tokens: int = 1024) -> str:
# Revalidate at the call boundary. Once admitted, generation always
# builds a --bare command; an environment change can make auth fail but
# can never silently fall back to hooks/plugins.
if not os.environ.get("ANTHROPIC_API_KEY"):
raise RuntimeError(
"claude optimizer generation requires API-key auth for --bare"
)
return self._call_claude(prompt, max_tokens=max_tokens, bare=True)

def _call_claude(
self,
prompt: str,
*,
max_tokens: int = 1024,
bare: bool,
) -> str:
# Run ISOLATED so the ambient Claude Code environment does not leak into
# the optimizer/target call. Critically, the user's GLOBAL skills
# (~/.claude/skills) are injected regardless of cwd, so we must disable
Expand All @@ -762,7 +846,7 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
# cwd=<clean temp> no project CLAUDE.md
import tempfile
cmd = [self.claude_path, "-p", "--output-format", "text"]
if os.environ.get("ANTHROPIC_API_KEY"):
if bare:
cmd.append("--bare")
cmd += [
"--disable-slash-commands",
Expand Down Expand Up @@ -1074,6 +1158,7 @@ class OpenCodeCliBackend(CliBackend):
"""Run SkillOpt model calls through the user's OpenCode CLI."""

name = "opencode"
generation_tools_disabled = True

def __init__(
self,
Expand Down Expand Up @@ -1237,11 +1322,20 @@ def _verify_tool_allowlist(self, env: Dict[str, str], work: str, agent: str, exp
if {name for name, enabled in tools.items() if enabled} != expected:
raise OpenCodeError("OpenCode CLI could not restrict tools to the replay allowlist")

def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str:
def _cached_call(
self,
key: str,
prompt: str,
*,
max_tokens: int = 1024,
call_fn: Optional[Callable[..., str]] = None,
) -> str:
"""Keep failed OpenCode calls out of the cache."""
if key in self._cache:
self.last_call_error = ""
out = super()._cached_call(key, prompt, max_tokens=max_tokens)
out = super()._cached_call(
key, prompt, max_tokens=max_tokens, call_fn=call_fn
)
if not out:
self._cache.pop(key, None)
return out
Expand All @@ -1263,6 +1357,7 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
env = self._build_child_environment(work, permission)
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config, separators=(",", ":"))
self._disable_and_verify_mcp(env, work, config)
self._verify_tool_allowlist(env, work, agent, set())
proc = self._run_process(
self._build_run_command(work, agent, "skillopt-sleep"),
env,
Expand Down Expand Up @@ -2155,6 +2250,7 @@ class DualBackend(Backend):
"""Route operations to two backends, à la SkillOpt's target vs optimizer.

* attempt -> TARGET backend (the model the skill is deployed on)
* generate -> OPTIMIZER backend (synthetic/optimizer-side material)
* reflect -> OPTIMIZER backend (the stronger/cheaper model writing edits)
* judge -> OPTIMIZER backend (graded by the optimizer when no local rule)

Expand All @@ -2173,6 +2269,9 @@ def __init__(self, target: Backend, optimizer: Backend) -> None:
def attempt(self, task, skill, memory, sample_id: int = 0):
return self.target.attempt(task, skill, memory, sample_id=sample_id)

def generate(self, prompt: str, *, max_tokens: int = 1024) -> str:
return self.optimizer.generate(prompt, max_tokens=max_tokens)

def attempt_with_tools(self, task, skill, memory, tools):
return self.target.attempt_with_tools(task, skill, memory, tools)

Expand Down Expand Up @@ -2229,6 +2328,7 @@ class AzureOpenAIBackend(CliBackend):
"""

name = "azure"
generation_tools_disabled = True

_COMPAT_MODES = {"openai_compatible", "compat", "openai"}
# Hosts the managed-identity (AAD bearer token) path may talk to. A custom
Expand Down
5 changes: 5 additions & 0 deletions skillopt_sleep/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
# ── dream + recall (opt-in; defaults reproduce the prior single-shot loop) ─
"dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task
"dream_factor": 0, # >0 => add N synthetic variants of each task to the dream
"llm_dream": False, # opt-in paraphrase generator; templates stay the default
"recall_k": 0, # >0 => recall the K most-similar past tasks into the dream
"evolve_memory": True, # consolidate CLAUDE.md
"evolve_skill": True, # consolidate the managed SKILL.md
Expand Down Expand Up @@ -227,6 +228,10 @@ def load_config(**overrides: Any) -> SleepConfig:
if value is not None:
data[key] = value
user_keys.add(key)
# This opt-in can spend provider tokens. Accept the JSON/YAML boolean only;
# values such as the string "false" are truthy in Python and must never
# accidentally enable generation.
data["llm_dream"] = data.get("llm_dream") is True
if data.get("projects") == "invoked" and not data.get("invoked_project"):
data["invoked_project"] = os.getcwd()
data["_user_config_keys"] = sorted(user_keys)
Expand Down
Loading