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
46 changes: 46 additions & 0 deletions docs/sleep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,52 @@ documents the separate HTTPS-only boundary for Azure managed-identity credential
Deterministic proof (no API key):
`python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves`.

### Nightly token budget

`max_tokens_per_night` in `~/.skillopt-sleep/config.json` (default `400000`)
stops a `run` or `dry-run` from starting more model work once tonight's spend
reaches it. The cycle checks the budget before the consolidation, before its
skill and memory edit steps, before the held-out test replay, and before each
per-skill fan-out group. Steps that did not start are listed in a note in
`report.md`, for example
`max_tokens_per_night reached (431870 of 400000 tokens); skipped: memory edits`.

```json
{"max_tokens_per_night": 200000}
```

Set the value to `0` or `null` for no budget. Anything other than a whole,
non-negative number stops the run with an error before any model call. There
is no elapsed-time budget.

This is not a hard cap. A step that has started always finishes, so a night
can end above the budget by the cost of one step: the first replay of the
validation and training tasks, one edit step (a training replay, a reflect
call and a validation replay of the candidate; with `dream_rollouts` set to K,
K attempts per training task), or the held-out test replay. Each fan-out group
that starts runs its own first replay.

Spend comes from the backend's token counter, the one the report header prints
as `tokens used`. The header shows the backend's running total and the note
shows tonight's spend; they are equal unless a caller reuses one backend object
across nights. What the counter covers depends on the backend:

- CLI backends estimate tokens from prompt and reply length. LLM task mining and
contrastive reflection (`dream_rollouts` above 1) call the model directly and
are not counted.
- `azure_openai` adds the usage reported by the API to that estimate, so it
counts mining, and replay and reflect calls count roughly twice.
- `handoff` counts the prompt and answer text of the current round. A budget
check that finds unanswered prompts pauses the round for answers instead of
stopping it, so a round only stops on the budget once every counted call has
a real answer.
- `mock` reports no usage and never reaches the budget.

A night that stops on the budget still advances the harvest checkpoint, so its
sessions are not mined again, and skipped work is not retried on a later night.
Tonight's tasks stay in the task archive, which later nights only use through
`recall_k`.

### Opt-in: per-skill fan-out

Set `"multi_skill_fanout": true` in `~/.skillopt-sleep/config.json` to add an
Expand Down
6 changes: 4 additions & 2 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ the shipping CLI defaults.
- `mock` and `handoff` make no network calls.
- `run` stages proposals; `adopt` is the normal live-change boundary.
- Adoption backs up existing target files.
- `--max-sessions` and `--max-tasks` bound work, but the main CLI does not yet
enforce a hard token or elapsed-time budget.
- `--max-sessions` and `--max-tasks` bound work. `max_tokens_per_night` in the
config file (default `400000`) stops a night from starting new steps once its
token count reaches the budget, but a started step finishes, so it is not a
hard cap. There is no elapsed-time budget.
- Treat real-backend transcript excerpts as data shared with the selected
provider.
6 changes: 4 additions & 2 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ The scheduled run stages proposals unless `--auto-adopt` is explicitly selected.
- **Read-only** harvest of `~/.claude`. `mock` replay has no side effects.
- Proposals are **staged**, never auto-applied (unless you opt in with `--auto-adopt`).
- Every adopt writes a backup under the staging dir's `backup/`.
- `--max-sessions` and `--max-tasks` bound work, but the main CLI does not enforce
a hard token or wall-clock budget.
- `--max-sessions` and `--max-tasks` bound work. `max_tokens_per_night` in
`~/.skillopt-sleep/config.json` stops a night from starting new steps once its
token count reaches the budget; a started step finishes, so it is not a hard
cap, and there is no wall-clock budget.
- Real backends share truncated session/task content with the selected provider;
do not assume outbound prompts have been fully redacted.

Expand Down
8 changes: 6 additions & 2 deletions plugins/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ staged files before changing a live skill.
paths all point to isolated test locations.

3. Review `config.json`. In particular, do not assume that values such as
`max_tokens_per_night` or `replay_mode` are enforced by this custom wrapper
merely because they appear in the example config.
`replay_mode` are enforced by this custom wrapper merely because they appear
in the example config. `max_tokens_per_night` is applied by the shared cycle,
but it is measured with the wrapper backend's rough `tokens_used()` estimate
(a quarter of the attempt prompt length plus 200 per attempt, 600 per rubric
judge call, and a third of the reflect prompt plus 1500 per reflect call), so
it is not a billing limit.

4. Supply credentials through your normal secret-management mechanism. Do not
commit a DeepSeek key or place it in a world-readable file.
Expand Down
44 changes: 41 additions & 3 deletions skillopt_sleep/consolidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import math
from dataclasses import dataclass, field
from typing import List, Tuple
from typing import Callable, List, Optional, Tuple

from skillopt_sleep.backend import Backend

Expand All @@ -23,6 +23,10 @@

_HAVE_REPO_GATE = True

# gate_action of an epoch that never ran because the token budget was spent.
# Its scores were not measured, so callers must not report them as results.
BUDGET_EXHAUSTED = "budget_exhausted"


def _finite_score(value: float) -> float | None:
"""Return a JSON-safe score, preserving finite values only."""
Expand Down Expand Up @@ -59,6 +63,8 @@ class ConsolidationResult:
# fresh final replay, so aggregate changes never hide an individual task.
# Keep new optional fields last to preserve positional construction.
gate_trials: List[dict] = field(default_factory=list)
# Steps that never started because the caller's token budget was spent.
budget_skipped: List[str] = field(default_factory=list)


def _split(tasks: List[TaskRecord]) -> Tuple[List[TaskRecord], List[TaskRecord], bool]:
Expand Down Expand Up @@ -192,6 +198,7 @@ def consolidate(
evolve_skill: bool = True,
evolve_memory: bool = True,
night: int = 1,
budget_exhausted: Optional[Callable[[], bool]] = None,
) -> ConsolidationResult:
"""Run one consolidation epoch: reflect -> bounded edit -> gate.

Expand All @@ -201,12 +208,42 @@ def consolidate(
recorded so the report shows whether quality moved.

Skill and memory are evolved in sequence (skill first if both enabled).

``budget_exhausted`` is checked before the epoch starts and before each
evolve target. Once it returns True, the steps that have not started are
skipped and listed in ``budget_skipped``. A started step always finishes,
so every gate decision still rests on complete evidence; the final replay
only re-scores documents an earlier replay already scored.
"""
from skillopt_sleep import evidence as evlog
ev = evlog.get(backend)
train_tasks, val_tasks, holdout_leaked = _split(tasks)
gate_off = str(gate_mode).strip().lower() in {"off", "none", "false", "greedy"}
holdout_detail: List[dict] = []
budget_skipped: List[str] = []

def _budget_spent(step: str) -> bool:
if budget_exhausted is None or not budget_exhausted():
return False
budget_skipped.append(step)
if ev is not None:
ev.log("budget", "skip", step=step, night=night)
return True

if _budget_spent("consolidation"):
return ConsolidationResult(
accepted=False,
gate_action=BUDGET_EXHAUSTED,
baseline_score=0.0,
candidate_score=0.0,
new_skill=skill,
new_memory=memory,
applied_edits=[],
rejected_edits=[],
holdout_baseline=0.0,
holdout_candidate=0.0,
budget_skipped=budget_skipped,
)

# ── baseline on the VAL slice (the gate reference) ────────────────────
# When the gate is OFF the user has opted out of holding out a validation set
Expand Down Expand Up @@ -307,7 +344,7 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
all_rejected.extend(applied)
return doc

if evolve_skill:
if evolve_skill and not _budget_spent("skill edits"):
if rollouts_k > 1:
# multi-rollout contrastive reflection: run each train task K times
# and distill a rule from the good-vs-bad contrast (the imagination signal).
Expand Down Expand Up @@ -354,7 +391,7 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
)
cand_skill = _gate_apply(cand_skill, edits, "skill")

if evolve_memory:
if evolve_memory and not _budget_spent("memory edits"):
# re-evaluate failures under the (possibly improved) skill
evlog.set_phase(backend, "train_post_skill")
train_pairs2 = replay_batch(backend, train_tasks, cand_skill, cand_memory)
Expand Down Expand Up @@ -493,4 +530,5 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
reflect_raw=getattr(backend, "last_reflect_raw", "") or "",
call_error=getattr(backend, "last_call_error", "") or "",
holdout_leaked=holdout_leaked,
budget_skipped=budget_skipped,
)
Loading