mirror of
https://github.com/microsoft/SkillOpt.git
synced 2026-07-07 16:49:08 +08:00
Adds a thin OpenClaw shell wrapping the SkillOpt-Sleep engine. Enables nightly validation-gated skill improvement cycles for OpenClaw agents. Components: - skillopt_sleep_openclaw.py: DeepSeek V4 Pro + Ollama nomic-embed-text backend, mirroring the Claude/Codex/Copilot backend pattern. - run_sleep.py: CLI entry point supporting dry-run and pre-built task files. - run_sleep_cron.sh: bash wrapper for nightly cron invocation. - slash_sleep.py: /sleep command (status / run / adopt / reject / cost). - config.json: engine config tuned for our stack. - SKILL.md: OpenClaw skill manifest. - tests/: 14 held-out tasks across 3 categories (research-cron, devops, wiki). OpenClaw is the 4th ecosystem in which SkillOpt-Sleep can be deployed, joining Claude Code, Codex, and Copilot. The shell follows the same single-engine / thin-shell pattern as the existing three plugins. End-to-end tested: pipeline runs against real OpenClaw session transcripts, gate correctly rejects non-improvements, staging artifacts land in ~/.skillopt-sleep/staging/<night>/. Cost: ~$0.02/night on DeepSeek V4 Pro.
276 lines
9.8 KiB
Python
276 lines
9.8 KiB
Python
"""OpenClaw backend for SkillOpt-Sleep.
|
|
|
|
Adapts the skillopt_sleep Backend protocol to our DeepSeek + Ollama stack:
|
|
- attempt/judge/reflect -> DeepSeek V4 Pro (or Flash for cost)
|
|
- embeddings -> Ollama nomic-embed-text (already configured)
|
|
|
|
This backend NEVER mutates live state. It only returns text + EditRecord
|
|
proposals that the gate stages for human review.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from skillopt_sleep.backend import Backend, _normalize, exact_score
|
|
from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord
|
|
|
|
|
|
# ── DeepSeek + Ollama OpenAI-compatible API client (curl-based, no extra deps) ──
|
|
|
|
|
|
def _chat(messages: List[Dict[str, str]], *, model: str, temperature: float = 0.2, max_tokens: int = 1500) -> str:
|
|
"""Call DeepSeek V4 Pro via curl + jq. No extra Python deps needed."""
|
|
import json as _json
|
|
import urllib.request
|
|
|
|
api_key = os.environ.get("DEEPSEEK_API_KEY", "")
|
|
if not api_key:
|
|
# try loading from .env
|
|
env_path = os.path.expanduser("~/.openclaw/.env")
|
|
if os.path.exists(env_path):
|
|
with open(env_path) as f:
|
|
for line in f:
|
|
if line.startswith("DEEPSEEK_API_KEY="):
|
|
api_key = line.split("=", 1)[1].strip()
|
|
break
|
|
|
|
base = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"stream": False,
|
|
}
|
|
req = urllib.request.Request(
|
|
f"{base}/chat/completions",
|
|
data=_json.dumps(payload).encode("utf-8"),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {api_key}",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=180) as resp:
|
|
data = _json.loads(resp.read().decode("utf-8"))
|
|
return data["choices"][0]["message"]["content"]
|
|
except Exception as e:
|
|
return f"[BACKEND_ERROR] {type(e).__name__}: {str(e)[:200]}"
|
|
|
|
|
|
def _embed(text: str) -> List[float]:
|
|
"""Call Ollama for embeddings. Uses the configured nomic-embed-text model."""
|
|
import json as _json
|
|
import urllib.request
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
"http://127.0.0.1:11434/api/embeddings",
|
|
data=_json.dumps({"model": "nomic-embed-text:latest", "prompt": text[:2000]}).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
data = _json.loads(resp.read().decode("utf-8"))
|
|
return data.get("embedding", [])
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# ── Backend implementation ────────────────────────────────────────────────────
|
|
|
|
|
|
class OpenClawDeepSeekBackend(Backend):
|
|
"""Use DeepSeek V4 Pro for attempt/judge/reflect, Ollama for embeddings.
|
|
|
|
- "model" passed to constructor = optimizer model (default: deepseek-v4-pro)
|
|
- "judge_model" = judge model (default: deepseek-v4-pro for quality)
|
|
- "cheap_model" = budget-fallback (deepseek-v4-flash)
|
|
"""
|
|
|
|
name = "openclaw-deepseek"
|
|
|
|
def __init__(
|
|
self,
|
|
model: str = "deepseek-v4-pro",
|
|
judge_model: str = "deepseek-v4-pro",
|
|
cheap_model: str = "deepseek-v4-flash",
|
|
):
|
|
self._model = model
|
|
self._judge_model = judge_model
|
|
self._cheap_model = cheap_model
|
|
self._tokens = 0 # rough estimate
|
|
|
|
def tokens_used(self) -> int:
|
|
return self._tokens
|
|
|
|
# ── 1. attempt: produce a response given the task + skill + memory ──
|
|
def attempt(self, task: TaskRecord, skill: str, memory: str) -> str:
|
|
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. "
|
|
"Be concise. No preamble, no explanation unless the task asks for it."
|
|
)
|
|
usr = f"""## SKILL
|
|
{skill or '(no skill yet)'}
|
|
|
|
## MEMORY
|
|
{memory or '(no memory yet)'}
|
|
|
|
## TASK
|
|
{task.intent}
|
|
|
|
## CONTEXT (if any)
|
|
{task.context_excerpt or '(none)'}
|
|
|
|
## RESPONSE
|
|
"""
|
|
out = _chat(
|
|
[{"role": "system", "content": sys}, {"role": "user", "content": usr}],
|
|
model=self._model,
|
|
temperature=0.2,
|
|
)
|
|
self._tokens += len(usr) // 4 + 200
|
|
return out
|
|
|
|
# ── 2. judge: score the response ──
|
|
def judge(self, task: TaskRecord, response: str) -> Tuple[float, float, str]:
|
|
# Hard score: exact-match against task.reference (if available)
|
|
hard = exact_score(task.reference or "", response)
|
|
|
|
# Soft score: LLM judge against rubric (reference if reference_kind=='rubric')
|
|
rubric_text = task.reference if task.reference_kind == "rubric" else ""
|
|
if rubric_text:
|
|
judge_prompt = f"""You are a strict grader. Score the response 0.0-1.0 against the rubric.
|
|
|
|
## TASK
|
|
{task.intent}
|
|
|
|
## REFERENCE
|
|
{task.reference or '(none)'}
|
|
|
|
## RUBRIC
|
|
{rubric_text}
|
|
|
|
## RESPONSE
|
|
{response[:3000]}
|
|
|
|
## INSTRUCTIONS
|
|
Return ONLY a single float 0.0-1.0 on one line. No explanation. No markdown.
|
|
"""
|
|
try:
|
|
j_out = _chat(
|
|
[{"role": "user", "content": judge_prompt}],
|
|
model=self._judge_model,
|
|
temperature=0.0,
|
|
max_tokens=20,
|
|
).strip()
|
|
soft = float(re.search(r"[\d.]+", j_out.splitlines()[0]).group())
|
|
soft = max(0.0, min(1.0, soft))
|
|
except Exception:
|
|
soft = hard
|
|
self._tokens += 600
|
|
else:
|
|
soft = hard
|
|
|
|
rationale = f"hard={hard:.2f} soft={soft:.2f}"
|
|
return hard, soft, rationale
|
|
|
|
# ── 3. reflect: produce bounded EditRecord proposals ──
|
|
def reflect(
|
|
self,
|
|
failures: List[Tuple[TaskRecord, ReplayResult]],
|
|
successes: List[Tuple[TaskRecord, ReplayResult]],
|
|
skill: str,
|
|
memory: str,
|
|
*,
|
|
edit_budget: int,
|
|
evolve_skill: bool,
|
|
evolve_memory: bool,
|
|
) -> List[EditRecord]:
|
|
# Compact digest of failures + successes
|
|
fail_digest = "\n".join(
|
|
f"- TASK: {t.intent[:200]}\n RESPONSE: {r.response[:300]}\n WHY FAIL: {r.judge_rationale or r.fail_reason or 'unknown'}\n REFERENCE: {t.reference[:200]}"
|
|
for t, r in failures[:5]
|
|
) or "(none)"
|
|
succ_digest = "\n".join(
|
|
f"- TASK: {t.intent[:150]} -> OK ({r.judge_rationale or 'high score'})"
|
|
for t, r in successes[:3]
|
|
) or "(none)"
|
|
|
|
rubric_text = ""
|
|
if failures:
|
|
rubric_text = f"\n\n## REFERENCE ANSWERS\n{chr(10).join(f'Q: {t.intent[:120]}\\nA: {t.reference}' for t, _ in failures[:3] if t.reference)}"
|
|
|
|
sys = (
|
|
"You are SkillOpt-Sleep's bounded-edit optimizer. Your job is to propose 1-4 MINIMAL text edits to a skill or memory document "
|
|
"that, if applied, would help future agents do better on the failed tasks. "
|
|
"NEVER propose adding new sections wholesale. NEVER delete entire sections. "
|
|
"Edit primitives: ADD (append a step/rule at end), DELETE (remove a specific line by exact match), REPLACE (swap a specific line for another by exact match). "
|
|
"If you cannot identify a clear, minimal improvement, return an empty list."
|
|
)
|
|
usr = f"""## CURRENT SKILL
|
|
{skill or '(empty)'}
|
|
|
|
## CURRENT MEMORY
|
|
{memory or '(empty)'}
|
|
|
|
## FAILED TASKS
|
|
{fail_digest}
|
|
|
|
## SUCCESSFUL TASKS
|
|
{succ_digest}
|
|
{rubric_text}
|
|
|
|
## CONSTRAINTS
|
|
- max {edit_budget} edits total
|
|
- edits go to {"skill + memory" if (evolve_skill and evolve_memory) else ("skill" if evolve_skill else "memory")}
|
|
- if evolve_skill=False, target="memory" only; if evolve_memory=False, target="skill" only
|
|
- target must be "skill" or "memory"
|
|
|
|
## OUTPUT FORMAT (JSON, no markdown)
|
|
{{"edits": [{{"op": "ADD"|"DELETE"|"REPLACE", "target": "skill"|"memory", "content": "the text to add or replace with", "old_text": "for REPLACE/DELETE, the exact line to find", "rationale": "one short sentence why"}}]}}
|
|
"""
|
|
out = _chat(
|
|
[{"role": "system", "content": sys}, {"role": "user", "content": usr}],
|
|
model=self._model,
|
|
temperature=0.4,
|
|
max_tokens=2000,
|
|
)
|
|
self._tokens += len(usr) // 3 + 1500
|
|
|
|
# parse
|
|
try:
|
|
# strip markdown fences if any
|
|
cleaned = out.strip()
|
|
if cleaned.startswith("```"):
|
|
cleaned = re.sub(r"^```[a-z]*\n?", "", cleaned)
|
|
cleaned = re.sub(r"\n?```$", "", cleaned)
|
|
data = json.loads(cleaned)
|
|
edits: List[EditRecord] = []
|
|
for e in data.get("edits", [])[:edit_budget]:
|
|
if e.get("op") not in ("ADD", "DELETE", "REPLACE"):
|
|
continue
|
|
target = e.get("target", "skill")
|
|
if target not in ("skill", "memory"):
|
|
continue
|
|
if not evolve_skill and target == "skill":
|
|
continue
|
|
if not evolve_memory and target == "memory":
|
|
continue
|
|
edits.append(EditRecord(
|
|
op=e["op"],
|
|
target=target,
|
|
content=e.get("content", ""),
|
|
old_text=e.get("old_text", ""),
|
|
rationale=e.get("rationale", ""),
|
|
))
|
|
return edits
|
|
except Exception as e:
|
|
# log + return empty list (no edit is better than a bad edit)
|
|
return []
|