"""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 []