Skip to content

Commit 38697fe

Browse files
authored
Merge pull request #151 from Alphaxalchemy/feat/sleep-evidence-chain
feat(sleep): per-night evidence chain + live prompt registry
2 parents b390817 + b12b54c commit 38697fe

13 files changed

Lines changed: 908 additions & 120 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ All notable changes to SkillOpt are documented here. This project adheres to
77
## [Unreleased]
88

99
### Added
10+
- Per-night SkillOpt-Sleep `evidence.jsonl` chains for reconstructing harvest,
11+
mining, replay, reflection, and gate decisions, plus a live prompt-template
12+
registry with user overrides.
1013
- Native SkillOpt-Sleep support for Cursor, including a local plugin command
1114
and skill, Cursor transcript harvesting, and an optional Cursor Agent CLI
1215
backend. Cursor tool-aware replay remains disabled pending live permission-

docs/sleep/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ experience → long-term competence).
4242
> temporarily disabled pending live permission-boundary validation.
4343
> Cursor and the model provider selected by Cursor may therefore receive
4444
> transcript-derived content.
45+
>
46+
> By default, each stateful night also writes a local `evidence.jsonl` under
47+
> the project staging tree (beside the report when one is staged); dry-runs
48+
> write evidence under the configured Sleep state directory. The log contains
49+
> best-effort-redacted, per-field-truncated copies
50+
> of miner, replay, judge, and reflection prompts and replies. Treat it as
51+
> sensitive local data and apply an appropriate retention policy. Set
52+
> `"evidence_log": false` to disable it; setting `"redact_secrets": false`
53+
> deliberately disables this defense-in-depth redaction.
4554
4655
## How to use it
4756

skillopt_sleep/backend.py

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ class Backend:
4747
name = "base"
4848
# Optional user preferences (free text) injected into reflect as a prior.
4949
preferences: str = ""
50+
# Optional per-night evidence log (skillopt_sleep.evidence.EvidenceLog).
51+
# Attached by the cycle; None => no observability overhead. The phase tag
52+
# labels which consolidation step subsequent replay calls belong to.
53+
evidence = None
54+
evidence_phase: str = ""
5055

5156
def attempt(self, task: TaskRecord, skill: str, memory: str,
5257
sample_id: int = 0) -> str:
@@ -330,11 +335,23 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
330335
raise NotImplementedError
331336

332337
def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str:
338+
kind = key.split(":", 1)[0]
339+
ev = getattr(self, "evidence", None)
333340
if key in self._cache:
341+
# cache hits log key-only (the full text is on the original miss event)
342+
if ev is not None:
343+
ev.log("replay", "model_call", kind=kind, cache_hit=True, key=key,
344+
phase=getattr(self, "evidence_phase", ""), backend=self.name,
345+
model=self.model)
334346
return self._cache[key]
335347
out = self._call(prompt, max_tokens=max_tokens)
336348
self._tokens += len(prompt) // 4 + len(out) // 4
337349
self._cache[key] = out
350+
if ev is not None:
351+
ev.log("replay", "model_call", kind=kind, cache_hit=False, key=key,
352+
phase=getattr(self, "evidence_phase", ""), backend=self.name,
353+
model=self.model, prompt=prompt, response=out,
354+
error=getattr(self, "last_call_error", "") or "")
338355
return out
339356

340357
# operations -----------------------------------------------------------
@@ -363,16 +380,15 @@ def attempt(self, task: TaskRecord, skill: str, memory: str,
363380
return self._cached_call(key, prompt, max_tokens=512)
364381
# generic path (mined daily-case tasks): neutral, content-filter-safe
365382
# wording. Apply the skill/memory as guidance, not as adversarial
366-
# "OVERRIDE everything" directives.
367-
prompt = (
368-
"Complete the following task for the user. Follow the skill and memory "
369-
"guidance below, including any output-format and length requirements. "
370-
"When a 'Learned preferences' rule sets an explicit limit (e.g. a length "
371-
"cap), prefer that rule over more general advice it refines.\n\n"
372-
f"# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
373-
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
374-
"Return ONLY the final answer text, nothing else."
375-
)
383+
# "OVERRIDE everything" directives. Template lives in the prompt
384+
# registry so the dashboard can display/override it live.
385+
from skillopt_sleep import prompts as prompt_registry
386+
prompt = prompt_registry.render("attempt", {
387+
"__SKILL__": skill or "(none)",
388+
"__MEMORY__": memory or "(none)",
389+
"__INTENT__": task.intent,
390+
"__CONTEXT__": task.context_excerpt,
391+
})
376392
# cache on (task, skill, memory) so identical hold-out re-scoring is free
377393
salt = f"s{sample_id}:" if sample_id else ""
378394
key = "attempt:" + salt + skill_hash(prompt)
@@ -395,11 +411,11 @@ def judge(self, task: TaskRecord, response: str) -> Tuple[float, float, str]:
395411
if task.reference_kind == "exact" and task.reference:
396412
hard = exact_score(task.reference, response)
397413
return hard, max(hard, keyword_soft_score(task.reference, response)), "exact(local)"
398-
prompt = (
399-
"Score how well the response satisfies the rubric, 0..1. "
400-
'Return ONLY JSON {"score": <0..1>, "reason": "..."}.\n\n'
401-
f"# Rubric\n{task.reference or task.intent}\n\n# Response\n{response}"
402-
)
414+
from skillopt_sleep import prompts as prompt_registry
415+
prompt = prompt_registry.render("judge", {
416+
"__RUBRIC__": task.reference or task.intent,
417+
"__RESPONSE__": response,
418+
})
403419
key = "judge:" + skill_hash(prompt)
404420
raw = self._cached_call(key, prompt, max_tokens=200)
405421
obj = _extract_json(raw, "object")
@@ -482,39 +498,20 @@ def _explain(c: str) -> str:
482498
# can't ask questions). We surface the benchmark's own rollout system
483499
# prompt (carried on TaskRecord.system) so proposed rules stay in-bounds.
484500
guard_text = _task_guardrail(failures)
485-
prompt = (
486-
"You are SkillOpt's optimizer. The agent keeps failing the recurring "
487-
f"tasks below. Propose at most {edit_budget} bounded edits to the "
488-
f"{target} document so it stops failing. Each edit MUST be a short, "
489-
"GENERAL, reusable rule or preference (never task-specific, never an "
490-
"answer to a single task). If exact failing criteria are listed, your "
491-
"edits MUST make future outputs satisfy every one of them.\n"
492-
"BE CONCRETE: quote the exact threshold, section name, or format from "
493-
"the criteria verbatim in your rule (e.g. write 'keep the entire "
494-
"response under 1200 characters', NOT 'respect length limits'). Vague "
495-
"rules do not change behavior; specific numeric/structural rules do.\n"
496-
"IMPORTANT: your edits are APPENDED to a 'Learned preferences' block; "
497-
"you CANNOT delete the existing instructions above. If the current "
498-
f"{target} text conflicts with a criterion (e.g. it says 'be exhaustive' "
499-
"but outputs must be under a character limit), write an explicit, "
500-
"forceful OVERRIDE rule stating it supersedes the conflicting "
501-
"instruction, and put the hard requirement first.\n"
502-
"HARD CONSTRAINT: every rule you write MUST be consistent with the "
503-
"'Task output contract' below (if shown). NEVER propose a rule that "
504-
"changes the required output format/language, tells the agent to ask "
505-
"the user a question, or otherwise violates that contract — such a "
506-
"rule scores ZERO because the evaluator cannot honor it.\n"
507-
'Return ONLY a JSON array: '
508-
'[{"op":"add|replace|delete","content":"<rule>","anchor":"<text to replace/delete, optional>","rationale":"<why>"}].\n\n'
509-
f"# Current {target}\n{cur_doc}\n"
510-
f"{guard_text}"
511-
f"{criteria_text}\n"
512-
f"{pref_text}\n\n"
513-
f"# Recurring failures\n{fail_text}"
514-
)
501+
from skillopt_sleep import prompts as prompt_registry
502+
prompt = prompt_registry.render("reflect", {
503+
"__EDIT_BUDGET__": str(edit_budget),
504+
"__TARGET__": target,
505+
"__CUR_DOC__": cur_doc,
506+
"__GUARD__": guard_text,
507+
"__CRITERIA__": criteria_text,
508+
"__PREFS__": pref_text,
509+
"__FAILURES__": fail_text,
510+
})
515511
# Call with one retry: transient non-JSON replies otherwise waste a whole
516512
# night (the gate sees no edits and rejects). A firmer second prompt
517513
# recovers most of these.
514+
ev = getattr(self, "evidence", None)
518515
arr = None
519516
for attempt in range(2):
520517
p = prompt if attempt == 0 else (
@@ -523,6 +520,11 @@ def _explain(c: str) -> str:
523520
)
524521
raw = self._call(p, max_tokens=1024)
525522
self._tokens += len(p) // 4 + len(raw) // 4
523+
if ev is not None:
524+
ev.log("reflect", "exchange", target=target, attempt=attempt + 1,
525+
backend=self.name, model=self.model,
526+
n_failures=len(failures), prompt=p, raw_reply=raw,
527+
error=getattr(self, "last_call_error", "") or "")
526528
arr = _extract_json(raw, "array")
527529
if isinstance(arr, list) and arr:
528530
break

skillopt_sleep/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040
# ── optimizer ──────────────────────────────────────────────────────────
4141
"backend": "mock", # "mock" | "claude" | "codex" | "copilot" | "cursor"
4242
"model": "", # backend-specific; "" => backend default
43+
# Dual-backend split (both empty => single backend above plays all roles).
44+
# target = the model whose skill is deployed (runs `attempt` rollouts);
45+
# optimizer = the model that mines tasks, judges rubrics, writes edits.
46+
"optimizer_backend": "",
47+
"optimizer_model": "",
48+
"target_backend": "",
49+
"target_model": "",
50+
"azure_endpoint": "", # explicit endpoint for azure/compat backends
4351
"gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter)
4452
"codex_path": "", # "" => auto-detect the real @openai/codex binary
4553
"cursor_path": "", # "" => auto-detect the Cursor Agent CLI
@@ -58,6 +66,9 @@
5866
"target_skill_path": "", # explicit SKILL.md target for repo-scoped agents
5967
"target_task_filter": True, # prefer mined tasks matching target_skill_path/text
6068
"progress": False, # print phase progress to stderr
69+
# ── observability ──────────────────────────────────────────────────────
70+
"evidence_log": True, # write per-night evidence.jsonl (full evidentiary chain)
71+
"evidence_max_chars": 4000, # per-field truncation cap for evidence events
6172
# ── adoption / safety ──────────────────────────────────────────────────
6273
"auto_adopt": False, # default: stage + require explicit `adopt`
6374
"managed_skill_name": "skillopt-sleep-learned",

skillopt_sleep/consolidate.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ def consolidate(
108108
109109
Skill and memory are evolved in sequence (skill first if both enabled).
110110
"""
111+
from skillopt_sleep import evidence as evlog
112+
ev = evlog.get(backend)
111113
train_tasks, val_tasks = _split(tasks)
112114
gate_off = str(gate_mode).strip().lower() in {"off", "none", "false", "greedy"}
113115
holdout_detail: List[dict] = []
@@ -120,12 +122,19 @@ def consolidate(
120122
if gate_off:
121123
base_hard, base_soft = 0.0, 0.0
122124
else:
125+
evlog.set_phase(backend, "baseline_val")
123126
base_pairs = replay_batch(backend, val_tasks, skill, memory)
124127
base_hard, base_soft = aggregate_scores(base_pairs)
125128
holdout_detail = _holdout_detail(base_pairs)
126129
base_score = select_gate_score(base_hard, base_soft, gate_metric, gate_mixed_weight)
130+
if ev is not None:
131+
ev.log("gate", "baseline", gate_mode=("off" if gate_off else "on"),
132+
n_train=len(train_tasks), n_val=len(val_tasks),
133+
hard=base_hard, soft=base_soft, score=base_score,
134+
metric=gate_metric, mixed_weight=gate_mixed_weight)
127135

128136
# ── reflect over TRAIN-split failures/successes ───────────────────────
137+
evlog.set_phase(backend, "train")
129138
train_pairs = replay_batch(backend, train_tasks, skill, memory)
130139
failures = [(t, r) for (t, r) in train_pairs if r.hard < 1.0]
131140
successes = [(t, r) for (t, r) in train_pairs if r.hard >= 1.0]
@@ -134,8 +143,15 @@ def consolidate(
134143
all_applied: List[EditRecord] = []
135144
all_rejected: List[EditRecord] = []
136145

146+
def _edits_payload(edits: List[EditRecord]) -> List[dict]:
147+
return [{"op": e.op, "content": e.content, "anchor": e.anchor,
148+
"rationale": e.rationale} for e in edits]
149+
137150
def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
138151
nonlocal cand_skill, cand_memory, base_score, all_applied, all_rejected
152+
if ev is not None:
153+
ev.log("reflect", "edits_returned", target=which,
154+
n_edits=len(edits), edits=_edits_payload(edits))
139155
if not edits:
140156
return doc
141157
new_doc, applied = apply_edits(doc, edits)
@@ -144,14 +160,24 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
144160
# gate OFF: accept greedily with NO val scoring (the daily-use path)
145161
if gate_off:
146162
all_applied.extend(applied)
163+
if ev is not None:
164+
ev.log("gate", "trial", target=which, mode="greedy",
165+
accepted=True, n_edits=len(applied))
147166
return new_doc
148167
# gate ON: score the candidate on the VAL slice, keep only if it improves
149168
trial_skill = new_doc if which == "skill" else cand_skill
150169
trial_memory = new_doc if which == "memory" else cand_memory
170+
evlog.set_phase(backend, f"gate_trial:{which}")
151171
pairs = replay_batch(backend, val_tasks, trial_skill, trial_memory)
152172
h, s = aggregate_scores(pairs)
153173
cand_score = select_gate_score(h, s, gate_metric, gate_mixed_weight)
154-
if cand_score > base_score:
174+
improved = cand_score > base_score
175+
if ev is not None:
176+
ev.log("gate", "trial", target=which, mode="gated",
177+
baseline_score=base_score, cand_hard=h, cand_soft=s,
178+
cand_score=cand_score, accepted=improved,
179+
n_edits=len(applied))
180+
if improved:
155181
base_score = max(base_score, cand_score)
156182
all_applied.extend(applied)
157183
return new_doc
@@ -204,6 +230,7 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
204230

205231
if evolve_memory:
206232
# re-evaluate failures under the (possibly improved) skill
233+
evlog.set_phase(backend, "train_post_skill")
207234
train_pairs2 = replay_batch(backend, train_tasks, cand_skill, cand_memory)
208235
failures2 = [(t, r) for (t, r) in train_pairs2 if r.hard < 1.0]
209236
successes2 = [(t, r) for (t, r) in train_pairs2 if r.hard >= 1.0]
@@ -225,6 +252,7 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
225252
base_gate_score = 0.0
226253
else:
227254
# scored on the VAL slice (the gate reference)
255+
evlog.set_phase(backend, "final_val")
228256
final_pairs = replay_batch(backend, val_tasks, cand_skill, cand_memory)
229257
final_hard, final_soft = aggregate_scores(final_pairs)
230258
final_score = select_gate_score(final_hard, final_soft, gate_metric, gate_mixed_weight)
@@ -249,6 +277,27 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
249277
action = "accept" if final_score > base_gate_score else "reject"
250278
accepted = bool(all_applied) and final_score > base_gate_score
251279

280+
if ev is not None:
281+
w = max(0.0, min(1.0, float(gate_mixed_weight)))
282+
if gate_metric == "mixed":
283+
formula = (
284+
f"score = (1-{w})*hard + {w}*soft; "
285+
f"baseline = (1-{w})*{base_hard:.3f} + {w}*{base_soft:.3f} = {base_gate_score:.3f}; "
286+
f"candidate = (1-{w})*{final_hard:.3f} + {w}*{final_soft:.3f} = {final_score:.3f}"
287+
)
288+
else:
289+
formula = (
290+
f"score = {gate_metric}; baseline = {base_gate_score:.3f}; "
291+
f"candidate = {final_score:.3f}"
292+
)
293+
ev.log("gate", "decision", action=action, accepted=accepted,
294+
baseline_score=base_gate_score, candidate_score=final_score,
295+
baseline_hard=base_hard, baseline_soft=base_soft,
296+
candidate_hard=final_hard, candidate_soft=final_soft,
297+
metric=gate_metric, mixed_weight=gate_mixed_weight,
298+
formula=formula, n_applied=len(all_applied),
299+
n_rejected=len(all_rejected), night=night)
300+
252301
return ConsolidationResult(
253302
accepted=accepted,
254303
gate_action=action,

0 commit comments

Comments
 (0)