dsh-open-dream-rsi 0.3.1 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,8 +40,8 @@ directory holding your `tasks.json`:
40
40
 
41
41
  ```bash
42
42
  # from the npm registry, a tarball, a checkout, or straight from GitHub — pick one:
43
- dsh plugin --profile <name> add dsh-open-dream-rsi # or pin: dsh-open-dream-rsi@0.2.1
44
- dsh plugin --profile <name> add /path/to/dsh-open-dream-rsi-0.2.1.tgz
43
+ dsh plugin --profile <name> add dsh-open-dream-rsi # or pin: dsh-open-dream-rsi@0.3.1
44
+ dsh plugin --profile <name> add /path/to/dsh-open-dream-rsi-0.3.1.tgz
45
45
  dsh plugin --profile <name> add /path/to/open-dream-rsi/plugins/deepseek_harness
46
46
  dsh plugin --profile <name> add github:patrykorwat/open-dream-rsi#path:plugins/deepseek_harness
47
47
 
@@ -58,6 +58,27 @@ dsh plugin --profile <name> add "github:patrykorwat/open-dream-rsi#<sha>&path:pl
58
58
  (The pin must point at a commit that already contains this directory —
59
59
  pnpm fetches sources, not releases.)
60
60
 
61
+ ## Updating
62
+
63
+ The bundle is self-contained (the Python server is vendored, no pip, no
64
+ build step), so updating is a re-install of the same package id:
65
+
66
+ ```bash
67
+ dsh plugin --profile <name> add dsh-open-dream-rsi
68
+ ```
69
+
70
+ Installed from git instead? Pin a commit that already contains the bundle
71
+ files — an older sha registers a synthetic `0.0.0` package and dsh dies in
72
+ `resolveBundleDir`:
73
+
74
+ ```bash
75
+ dsh plugin --profile <name> add github:patrykorwat/open-dream-rsi#<sha>&path:plugins/deepseek_harness
76
+ ```
77
+
78
+ Verify the installed version afterwards with your profile's plugin list
79
+ (see `dsh plugin --help`). Re-installs never touch your memory:
80
+ `tasks.json` / `.dream_rsi` live beside the directory dsh launches from.
81
+
61
82
  ## Install (stdio, `--patch` overlay)
62
83
 
63
84
  1. Make the server importable (not on PyPI — install from the repo):
@@ -46,7 +46,7 @@ from open_dream_rsi.bench_policy import (
46
46
  run_policy_benchmark,
47
47
  )
48
48
 
49
- __version__ = "0.2.1"
49
+ __version__ = "0.3.8"
50
50
 
51
51
  __all__ = [
52
52
  "DiscoveryTree",
@@ -19,8 +19,8 @@ import json
19
19
  import shutil
20
20
  import tempfile
21
21
  import time
22
- from dataclasses import asdict, dataclass, field
23
- from typing import Any, Callable, Dict, List, Optional
22
+ from dataclasses import asdict, dataclass
23
+ from typing import Any, Dict, List, Optional
24
24
 
25
25
  from open_dream_rsi.loop import AutoRSIRuntime, Task
26
26
  from open_dream_rsi.memory import DreamMemory
@@ -85,6 +85,9 @@ def run_arm(
85
85
  # benchmarks (bench-policy) — keep the machinery under test pure.
86
86
  enable_knowledge=False,
87
87
  enable_thoughts=False,
88
+ # Published arm numbers are byte-stable: the fair queue must not
89
+ # reorder these tasks (benchmarks size their budget per task set).
90
+ fair_queue=False,
88
91
  )
89
92
  runtime.api_calls_used = 0
90
93
  report = runtime.run_once()
@@ -20,6 +20,11 @@ expansion strategy differs):
20
20
  * ``evolved_policy`` — the loop asks the LLM for a policy program per
21
21
  category, gates it by counterfactual rollout, and (on evidence) steers
22
22
  online expansion with the promoted code.
23
+ * ``sentinel_guided`` — same, but the recorded tree carries structured
24
+ Sentinel facts: nodes whose error class keeps repeating are recorded
25
+ BLOCKED (choice removal, the TravelPlanner-measured mechanism), and the
26
+ promoted policy reads ``sentinel_blocked`` to skip them — structure
27
+ over luck: no epsilon, no lessons, no thoughts.
23
28
 
24
29
  The scripted model plays two roles deterministically (like ``MockLLM`` in
25
30
  ``bench.py``): a task solver following the family ladder, and a policy
@@ -37,10 +42,13 @@ import re
37
42
  import tempfile
38
43
  import time
39
44
  from dataclasses import asdict, dataclass, field
40
- from typing import Any, Dict, List, Optional, Sequence
45
+ from typing import Any, Dict, List, Sequence
46
+
47
+ from pathlib import Path
41
48
 
42
49
  from open_dream_rsi.loop import AutoRSIRuntime, Task
43
50
  from open_dream_rsi.memory import DreamMemory
51
+ from open_dream_rsi.sentinel import SentinelEngine, SentinelObservation
44
52
 
45
53
  # ---------------------------------------------------------------------------
46
54
  # The decoy-trap suite. Each category has exactly three code families whose
@@ -156,6 +164,63 @@ TRAP_LESSON_TEXT = (
156
164
  )
157
165
 
158
166
 
167
+ #: The policy program the scripted policy-writer returns when the recorded
168
+ #: tree carries Sentinel repeats (the generation prompt's tree summary shows
169
+ #: ``same_error=`` lines): the structural explorer (oldest fresh node,
170
+ #: periodic root re-entry) restricted to nodes whose error class has NOT
171
+ #: kept repeating. The blocked facts prune the dead side-chains from the
172
+ #: exploration queue, so the promising branch is reached with fewer live
173
+ #: attempts; on a fact-free tree the same code reduces to the plain
174
+ #: explorer (asserted in tests/test_bench_policy.py).
175
+ SENTINEL_EXPLORER_POLICY = """
176
+ def choose_action(frontier, step):
177
+ if not frontier:
178
+ return None
179
+ def born(n):
180
+ tail = n['node_id'].rsplit('/', 1)[-1]
181
+ digits = ''.join(ch for ch in tail if ch.isdigit())
182
+ return int(digits) if digits else -1
183
+ clean = [n for n in frontier if not n.get('sentinel_blocked')]
184
+ pool = clean or frontier
185
+ fresh = [n for n in pool if n['children'] == 0]
186
+ if fresh and step % 3 != 0:
187
+ return min(fresh, key=born)['node_id']
188
+ return min(pool, key=born)['node_id']
189
+ """
190
+
191
+
192
+ class _RepeatBlockSentinel:
193
+ """Benchmark-side engine wrapper: repeats become BLOCKED (choice removal).
194
+
195
+ The runtime's ``observe_structured()`` path records replay-safe facts on
196
+ each node; this wrapper composes the TravelPlanner-measured mechanism —
197
+ once an error class has repeated >= threshold inside the episode, the
198
+ observation is marked blocked, so the frontier feature (and any policy
199
+ reading it) treats the branch as removed. Pure composition: the engine
200
+ itself never blocks (SentinelObservation is rebuilt, never mutated).
201
+ """
202
+
203
+ def __init__(self, engine: SentinelEngine, repeat_threshold: int) -> None:
204
+ self.engine = engine
205
+ self.repeat_threshold = int(repeat_threshold)
206
+
207
+ def observe_structured(self, tool, result, session_id, status=None,
208
+ error_message=None, budget=None, blocked=False):
209
+ obs = self.engine.observe_structured(
210
+ tool, result, session_id, status=status,
211
+ error_message=error_message, budget=budget, blocked=blocked)
212
+ if (obs.error_class is not None
213
+ and obs.in_session_count >= self.repeat_threshold):
214
+ obs = SentinelObservation(
215
+ signature=obs.signature, error_class=obs.error_class,
216
+ in_session_count=obs.in_session_count,
217
+ cross_session_count=obs.cross_session_count,
218
+ recurring=obs.recurring, finalize_nudge=obs.finalize_nudge,
219
+ budget_used=obs.budget_used, budget_total=obs.budget_total,
220
+ blocked=True)
221
+ return obs
222
+
223
+
159
224
  #: One-line PLAN texts the scripted solver states per code family when
160
225
  #: thoughts are requested (system prompt mentions PLAN). Plain English — no
161
226
  #: family code appears here, so the ideas ledger can never leak code into
@@ -262,6 +327,10 @@ class TrapSolver:
262
327
  user = messages[-1]["content"]
263
328
  if "exploration policy" in system:
264
329
  self.policy_calls += 1
330
+ if "same_error=" in user:
331
+ # the tree summary shows Sentinel repeats: write the
332
+ # blocked-aware explorer instead of the plain round-robin
333
+ return f"```python\n{SENTINEL_EXPLORER_POLICY}\n```"
265
334
  return f"```python\n{EXPLORER_POLICY}\n```"
266
335
  if "knowledge curator" in system:
267
336
  self.curator_calls += 1
@@ -338,6 +407,15 @@ ARMS: Dict[str, Dict[str, Any]] = {
338
407
  # KB. Any improvement over epsilon_greedy is attributable to thoughts.
339
408
  "thought_guided": dict(enable_policy_code=False, enable_knowledge=False,
340
409
  explore_epsilon=0.3, enable_thoughts=True),
410
+ # Sentinel-guided arm: the runtime gets a repeat-block Sentinel engine
411
+ # (wired by run_policy_arm from the sentinel_repeat_block key, which
412
+ # must never reach AutoRSIRuntime itself); the LLM-written policy reads
413
+ # sentinel_blocked and stops re-picking nodes whose error class keeps
414
+ # repeating — the TravelPlanner choice-removal mechanism, ported to
415
+ # exploration structure. No epsilon, no lessons, no thoughts.
416
+ "sentinel_guided": dict(enable_policy_code=True, enable_knowledge=False,
417
+ enable_thoughts=False, explore_epsilon=0.0,
418
+ sentinel_repeat_block=2),
341
419
  }
342
420
 
343
421
 
@@ -376,17 +454,31 @@ def run_policy_arm(arm: str, cfg: Dict[str, Any], cycles: int, budget: int,
376
454
  t0 = time.time()
377
455
  total_solves = total_calls = total_policy = total_curator = 0
378
456
  rates: List[List[float]] = []
457
+ # The sentinel arm wires a repeat-block engine into the runtime: error
458
+ # classes repeating >= threshold inside the episode are recorded BLOCKED
459
+ # on the node, surfacing as the sentinel_blocked frontier feature the
460
+ # promoted policy reads. The raw SentinelEngine only observes; the
461
+ # repeat->blocked mapping is the benchmark composition of the
462
+ # TravelPlanner gate (choice removal recorded on the world).
463
+ block_cfg = int(cfg.get("sentinel_repeat_block") or 0)
464
+ arm_cfg = {k: v for k, v in cfg.items() if k != "sentinel_repeat_block"}
379
465
  for seed in seeds:
380
466
  tasks = _trap_tasks()
381
467
  solver = TrapSolver()
382
468
  root = tempfile.mkdtemp(prefix=f"odr-polbench-{arm}-")
469
+ engine = None
470
+ if block_cfg:
471
+ engine = _RepeatBlockSentinel(
472
+ SentinelEngine(state_path=Path(root) / "sentinel_state.json"),
473
+ block_cfg)
383
474
  rates.append([])
384
475
  for c in range(cycles):
385
476
  memory = DreamMemory(root) # persistent across cycles within a run
386
477
  runtime = AutoRSIRuntime(
387
478
  client=solver, memory=memory, tasks=tasks,
388
479
  api_call_budget=budget, dream_iterations=dream_iterations,
389
- rng_seed=seed * 100 + c, **cfg,
480
+ rng_seed=seed * 100 + c, fair_queue=False,
481
+ sentinel_engine=engine, **arm_cfg,
390
482
  )
391
483
  runtime.api_calls_used = 0
392
484
  report = runtime.run_once()
@@ -450,11 +542,14 @@ def to_markdown(summary: Dict[str, Any]) -> str:
450
542
  "trap tasks and then burns its whole budget re-polishing the decoy; "
451
543
  "replay-gated LLM-written policies reach the hidden fixes and reuse "
452
544
  "them as warm starts; the curated knowledge base lets epsilon alone "
453
- "spend luck elsewhere because the trap itself is remembered.", ""]
545
+ "spend luck elsewhere because the trap itself is remembered; the "
546
+ "sentinel-guided arm re-derives the same escape from structured "
547
+ "failure facts — nodes whose error class keeps repeating are "
548
+ "skipped, no luck and no lesson text required.", ""]
454
549
  return "\n".join(lines)
455
550
 
456
551
 
457
- def to_svg(summary: Dict[str, Any], width: int = 860, height: int = 300,
552
+ def to_svg(summary: Dict[str, Any], width: int = 960, height: int = 300,
458
553
  theme: str = "dark") -> str:
459
554
  """Grouped bar chart: solve rate (%) per cycle per arm. Pure SVG, no deps.
460
555
 
@@ -465,9 +560,11 @@ def to_svg(summary: Dict[str, Any], width: int = 860, height: int = 300,
465
560
  cycles = summary["cycles"]
466
561
  dark = theme == "dark"
467
562
  colors = ({"greedy": "#e5534b", "epsilon_greedy": "#d4a72c", "evolved_policy": "#3fb950",
468
- "knowledge_curator": "#58a6ff", "thought_guided": "#bc8cff"} if dark else
563
+ "knowledge_curator": "#58a6ff", "thought_guided": "#bc8cff",
564
+ "sentinel_guided": "#f0883e"} if dark else
469
565
  {"greedy": "#c0392b", "epsilon_greedy": "#b8860b", "evolved_policy": "#1e7d3c",
470
- "knowledge_curator": "#1f6fb2", "thought_guided": "#6f42c1"})
566
+ "knowledge_curator": "#1f6fb2", "thought_guided": "#6f42c1",
567
+ "sentinel_guided": "#d97706"})
471
568
  palette = dict(bg="#0d1117", grid="#21262d", axis="#8b949e", text="#c9d1d9") if dark \
472
569
  else dict(bg="#ffffff", grid="#d8dee4", axis="#57606a", text="#24292f")
473
570
  pad_l, pad_r, pad_t, pad_b = 48, 16, 30, 46
@@ -510,7 +607,7 @@ def to_svg(summary: Dict[str, Any], width: int = 860, height: int = 300,
510
607
  lx += 150
511
608
  parts.append(f'<text x="{pad_l}" y="18" fill="{palette["text"]}" font-size="13" '
512
609
  f'font-weight="600">Decoy-trap suite — solve rate per cycle '
513
- f'(greedy vs ε-greedy vs replay-gated policies vs curated KB vs thoughts)</text>')
610
+ f'({len(arms)} arms, mean over {summary.get("runs", 1)} seeds)</text>')
514
611
  parts.append("</svg>")
515
612
  return "\n".join(parts)
516
613
 
@@ -60,6 +60,7 @@ def cmd_loop(args: argparse.Namespace) -> int:
60
60
  interval_seconds=args.interval,
61
61
  max_tokens=args.max_tokens,
62
62
  enable_policy_code=not args.no_policy_code,
63
+ enable_evolution_stop=bool(getattr(args, "evolution_stop", False)),
63
64
  enable_knowledge=not args.no_knowledge,
64
65
  enable_thoughts=not args.no_thoughts,
65
66
  enable_judge=not args.no_judge,
@@ -354,6 +355,8 @@ def cmd_artifacts(args: argparse.Namespace) -> int:
354
355
  def main(argv: list[str] | None = None) -> int:
355
356
  parser = argparse.ArgumentParser(prog="odr", description="Open Dream-RSI autonomous supervisor")
356
357
  parser.add_argument("--memory", default=".dream_rsi", help="memory directory (one per instance)")
358
+ from open_dream_rsi import __version__ as _v
359
+ parser.add_argument("--version", action="version", version=f"open-dream-rsi {_v}")
357
360
  sub = parser.add_subparsers(dest="command", required=True)
358
361
 
359
362
  loop = sub.add_parser("loop", help="run the self-improvement cycle")
@@ -369,6 +372,10 @@ def main(argv: list[str] | None = None) -> int:
369
372
  loop.add_argument("--dream-iters", type=int, default=60)
370
373
  loop.add_argument("--no-policy-code", action="store_true",
371
374
  help="disable LLM-written exploration policies (section-3 step)")
375
+ loop.add_argument("--evolution-stop", action="store_true",
376
+ help="anytime-valid stopping for policy evolution "
377
+ "(freeze regeneration after a betting-martingale "
378
+ "alarm; the last promoted policy stays active)")
372
379
  loop.add_argument("--no-knowledge", action="store_true",
373
380
  help="disable the knowledge curator (lessons.json KB, section-4 step)")
374
381
  loop.add_argument("--no-thoughts", action="store_true",
@@ -407,7 +414,7 @@ def main(argv: list[str] | None = None) -> int:
407
414
  help="force one KB category (default: per-episode cwd)")
408
415
  dr.add_argument("--budget", type=int, default=20,
409
416
  help="max API calls for a FULL dream (default 20)")
410
- dr.add_argument("--max-tokens", type=int, default=4096)
417
+ dr.add_argument("--max-tokens", type=int, default=100_000)
411
418
  dr.add_argument("--offline", action="store_true",
412
419
  help="no LLM: lifecycle maintenance only")
413
420
  dr.set_defaults(func=cmd_dream)
@@ -1,4 +1,4 @@
1
- from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
1
+ from typing import Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
2
2
 
3
3
  from open_dream_rsi.core.tree import DiscoveryTree
4
4
  from open_dream_rsi.core.dreamer import DreamEngine
@@ -23,8 +23,10 @@ A distilled payload is a JSON array of at most ``max_lessons`` objects::
23
23
  {"trigger": str, "text": str}
24
24
 
25
25
  ``trigger``: 1-4 lowercase keywords tying the lesson to a task/failure
26
- family; ``text``: one actionable insight (<= 280 chars) grounded in the
27
- failure evidence shown to the curator. Generic advice is rejected.
26
+ family; ``text``: one actionable insight (<= 3000 chars) grounded in the
27
+ failure evidence shown to the curator — the observation, the mechanism
28
+ that made it fail, and the bounded fix with its stop condition. Generic
29
+ advice is rejected.
28
30
  """
29
31
 
30
32
  from __future__ import annotations
@@ -38,14 +40,16 @@ from typing import Any, Dict, List, Optional, Tuple
38
40
  #: Hard caps enforced by the structural gate — oversized or vague records
39
41
  #: never enter the knowledge base (cheap gate: lessons are retrieved text,
40
42
  #: so contamination is the failure mode to defend against).
41
- MAX_LESSON_CHARS = 280
43
+ MAX_LESSON_CHARS = 3_000
42
44
  MIN_TRIGGER_WORDS = 1
43
45
  MAX_TRIGGER_WORDS = 4
44
46
  MAX_LESSONS_PER_CATEGORY = 8
45
47
  #: Retrieval: how many lessons a single proposal prompt may carry and the
46
- #: total character budget for them (keeps the solver prompt honest).
48
+ #: total character budget for them (keeps the solver prompt honest). The
49
+ #: budget scales with the text cap so a detailed lesson is never silently
50
+ #: dropped at retrieval time (select_lessons skips what does not fit).
47
51
  MAX_LESSONS_IN_PROMPT = 4
48
- MAX_PROMPT_LESSON_CHARS = 700
52
+ MAX_PROMPT_LESSON_CHARS = 15_000
49
53
  #: Pruning: a lesson used this many times with zero recorded solves is
50
54
  #: dead weight and gets retired to keep the KB from rotting. Retirement is
51
55
  #: a LIFECYCLE transition (STALE -> ARCHIVED via the Artifact Lifecycle
@@ -84,14 +88,20 @@ LESSON_CONTRACT = (
84
88
  "Reply with ONLY one fenced ```json``` block containing an array of "
85
89
  "objects, each exactly: {{\"trigger\": \"...\", \"text\": \"...\"}}. "
86
90
  "'trigger' is 1-4 lowercase keywords tying the lesson to this failure "
87
- "family; 'text' is one actionable insight in at most 280 characters, "
88
- "grounded ONLY in the evidence shown. Phrase the insight as a "
91
+ "family; 'text' is one actionable insight in at most 3000 characters, "
92
+ "grounded ONLY in the evidence shown. Be SPECIFIC, not terse: name the "
93
+ "observable signal (the exact error shape, tool, or state that "
94
+ "recurs), the mechanism that made the attempt fail, and the bounded "
95
+ "behavior that avoids it - a lesson a future reader could act on "
96
+ "without seeing this evidence is the goal. Prefer the longer end of "
97
+ "the range when the evidence supports it; do not compress a real "
98
+ "mechanism into a slogan. Phrase the insight as a "
89
99
  "DECLARATIVE fact about the task family, never as a command to the "
90
100
  "solver: measured on a greedy decoder, the imperative form of exact "
91
101
  "true facts lost 12 paired comparisons with 0 wins against their "
92
102
  "declarative form. EVERY lesson MUST end with an "
93
103
  "explicit stop condition (e.g. '...then answer immediately', '...at "
94
- "most one extra lookup') — never instruct open-ended searching: models "
104
+ "most one extra lookup') - never instruct open-ended searching: models "
95
105
  "with deterministic decoding treat 'keep checking' as a command to "
96
106
  "never finish. No generic advice ('write better code'), no "
97
107
  "task-specific constants, no restate-the-error entries; if the evidence "
@@ -380,7 +390,7 @@ class KnowledgeCurator:
380
390
  """
381
391
 
382
392
  def __init__(self, client: Any, max_lessons: int = 3,
383
- max_repair: int = 1, max_tokens: int = 4096):
393
+ max_repair: int = 1, max_tokens: int = 100_000):
384
394
  self.client = client
385
395
  self.max_lessons = max_lessons
386
396
  self.max_repair = max_repair
@@ -68,6 +68,13 @@ from open_dream_rsi.sandbox import run_isolated
68
68
  #: score ones — the counterfactual counterpart of UCB's visit-count term).
69
69
  #: Applied identically to candidates and the greedy baseline.
70
70
  DIVERSITY_BETA = 0.2
71
+
72
+ #: Weight of the discovery-efficiency term (Dream-RSI v2 scores policies on
73
+ #: completed rounds per call; over a recorded tree the analog is the share of
74
+ #: rollout steps that harvest a NEW (node, reward) pair — a step re-paying a
75
+ #: value already recorded from that node is a wasted call on an exhausted
76
+ #: branch). Applied identically to candidate and baseline.
77
+ EFFICIENCY_BETA = 0.1
71
78
  #: Fraction of rollout steps that must return a frontier-valid choice for a
72
79
  #: candidate to be promotable at all.
73
80
  MIN_VALIDITY = 0.8
@@ -99,7 +106,10 @@ POLICY_CONTRACT = (
99
106
  "that will fail hidden tests forever — re-expanding it burns the budget, "
100
107
  "while branches with different (or no) failures hide the real prize. "
101
108
  "Good policies balance exploiting promising branches against trying "
102
- "under-expanded ones. "
109
+ "under-expanded ones. Policies are also scored on DISCOVERY "
110
+ "EFFICIENCY: a step that re-pays a value already recorded from the "
111
+ "same node (an exhausted branch) is a wasted call — do not camp on "
112
+ "branches whose recorded children you have already harvested. "
103
113
  "Rules: pure stdlib, no imports, no file/network/process access, no "
104
114
  "leading-underscore attributes, must terminate, must handle an empty "
105
115
  "frontier (return None)."
@@ -512,10 +522,32 @@ def _simulate_step_rewards(world: Dict[str, Any], pick_fn: Any) -> Tuple[List[fl
512
522
 
513
523
 
514
524
  def _rollout_objective(rewards: List[float], picks: List[str]) -> float:
515
- """Mean reward + branch-diversity bonus (beta_2), comparable across arms."""
525
+ """Mean reward + diversity bonus - discovery-inefficiency penalty.
526
+
527
+ The efficiency term operationalizes Dream-RSI v2's rounds-per-call
528
+ efficiency over a recorded tree: a rollout step that pays a (node,
529
+ reward) pair ALREADY harvested from that node re-plays an exhausted
530
+ ladder — a call producing no new discovery. e = fresh steps / steps;
531
+ camping on one branch pays its recorded children once and then burns
532
+ the horizon re-harvesting the last one. (Note: this is NOT payoff
533
+ latency — an earlier measurement showed latency mis-ranks structural
534
+ explorers below lucky ladder-order harvests on decoy fixtures.)
535
+ """
516
536
  total = len(rewards) or 1
537
+ mean = sum(rewards) / total
517
538
  distinct = len({p for p in picks if p != "!invalid"})
518
- return sum(rewards) / total + DIVERSITY_BETA * distinct / total
539
+ obj = mean + DIVERSITY_BETA * distinct / total
540
+ seen: set = set()
541
+ fresh = 0
542
+ for pick, r in zip(picks, rewards):
543
+ if pick == "!invalid":
544
+ continue # already punished by reward 0 + the validity floor
545
+ key = (pick, round(r, 9))
546
+ if key not in seen:
547
+ seen.add(key)
548
+ fresh += 1
549
+ obj -= EFFICIENCY_BETA * (1.0 - fresh / total)
550
+ return obj
519
551
 
520
552
 
521
553
  def rollout_score(
@@ -677,10 +709,14 @@ class PolicyGenerator:
677
709
  continue
678
710
  if source == incumbent_source:
679
711
  return GenerationResult(None, score, "identical to incumbent")
680
- if score < incumbent_score - 1e-9:
712
+ if score <= incumbent_score + 1e-9:
713
+ # STRICT improvement only: a tie is not evidence (the same
714
+ # philosophy as the lesson gate's confirmed-strict-win rule).
715
+ # A tying challenger must not displace a trusted incumbent.
681
716
  return GenerationResult(
682
717
  None, score,
683
- f"replay score {score:.4f} < incumbent {incumbent_score:.4f}")
718
+ f"replay score {score:.4f} did not strictly beat "
719
+ f"incumbent {incumbent_score:.4f}")
684
720
  return GenerationResult(source, score, "")
685
721
  return GenerationResult(None, float("-inf"), feedback or "generation failed")
686
722
 
@@ -19,7 +19,7 @@ import os
19
19
  import shutil
20
20
  import subprocess
21
21
  import sys
22
- from typing import Any, Dict, List, Optional
22
+ from typing import Dict, List, Optional
23
23
 
24
24
  #: Hard ceiling for one headless agent turn. The curator's distill prompt is
25
25
  #: small; a hang here must not stall the dream cycle.
@@ -144,6 +144,46 @@ def _append_probe_tasks(memory_root: Path, probes: List[Dict[str, Any]]) -> int:
144
144
  return added
145
145
 
146
146
 
147
+ #: Queue backstop: how many probe tasks may sit in tasks.json once they have
148
+ #: been processed. The fair queue (AutoRSIRuntime.fair_queue) is the actual
149
+ #: starvation fix — this cap only retires OLD, already-attempted probes.
150
+ MAX_PROBE_TASKS = 60
151
+
152
+
153
+ def _cap_probe_queue(memory_root: Path) -> int:
154
+ """Retire old probe tasks AFTER they were processed (backstop, not the
155
+ fix). A probe that never got an attempt is NEVER dropped — unprocessed
156
+ work always deserves its turn, and the fair queue guarantees it gets
157
+ one before the tail of the backlog. Returns the number removed.
158
+ """
159
+ tp = memory_root / "tasks.json"
160
+ try:
161
+ tasks = json.loads(tp.read_text(encoding="utf-8"))
162
+ except (OSError, json.JSONDecodeError):
163
+ return 0
164
+ probes = [(i, t) for i, t in enumerate(tasks)
165
+ if str(t.get("task_id", "")).startswith("probe:")]
166
+ if len(probes) <= MAX_PROBE_TASKS:
167
+ return 0
168
+ try:
169
+ attempts = json.loads((memory_root / "task_attempts.json")
170
+ .read_text(encoding="utf-8"))
171
+ except (OSError, json.JSONDecodeError):
172
+ attempts = {}
173
+ # probe ids embed a sortable timestamp: oldest first
174
+ probes.sort(key=lambda it: str(it[1].get("task_id", "")))
175
+ stale = probes[:-MAX_PROBE_TASKS]
176
+ drop = {i for i, t in stale
177
+ if int(attempts.get(str(t.get("task_id")), 0)) > 0}
178
+ if not drop:
179
+ return 0
180
+ out = [t for i, t in enumerate(tasks) if i not in drop]
181
+ tmp = tp.with_suffix(".json.tmp")
182
+ tmp.write_text(json.dumps(out, indent=1), encoding="utf-8")
183
+ tmp.replace(tp)
184
+ return len(drop)
185
+
186
+
147
187
  def ingest_sessions(memory, sessions_db: "str | Path", *,
148
188
  source: str = "hermes",
149
189
  limit: int = 25, client=None,
@@ -262,7 +302,7 @@ def dream_once(memory_root: "str | Path", *,
262
302
  session_source: str = "hermes",
263
303
  skills_out: "str | Path | None" = None,
264
304
  client=None, tasks: Optional[List[Any]] = None,
265
- budget: int = 20, max_tokens: int = 4096,
305
+ budget: int = 20, max_tokens: int = 100_000,
266
306
  category: Optional[str] = None,
267
307
  min_new_evidence: int = DEFAULT_MIN_NEW_EVIDENCE,
268
308
  max_age_seconds: float = DEFAULT_MAX_AGE_SECONDS) -> Dict[str, Any]:
@@ -311,6 +351,12 @@ def dream_once(memory_root: "str | Path", *,
311
351
  state["last_full_dream"] = time.time()
312
352
  _save_state(root, state)
313
353
 
354
+ # 3b. queue backstop — AFTER processing, never before. Starvation is
355
+ # fixed by the loop's fair queue (least-processed task first); this
356
+ # cap only retires probes that already had their one attempt. A probe
357
+ # never attempted is NEVER dropped — it still deserves its turn.
358
+ report["queue_pruned"] = _cap_probe_queue(root)
359
+
314
360
  # 4. publish earned knowledge (cheap; surfaces new ACTIVE lessons)
315
361
  if skills_out:
316
362
  report["skills"] = len(lessons_to_skills(memory, skills_out))
@@ -0,0 +1,88 @@
1
+ """Anytime-valid stopping for policy evolution.
2
+
3
+ Inspired by "When Is Enough Enough in Self-Evolving LLM Systems?"
4
+ (arXiv:2610.04756): self-evolving loops that run to a fixed budget waste
5
+ compute after improvement saturates and risk overfitting the evaluation
6
+ signal with late updates. The paper's answer is a betting-martingale
7
+ stopping rule over paired evaluation outcomes plus a change-point choice
8
+ of the artifact to output.
9
+
10
+ Mapped onto this loop (policy evolution only, opt-in):
11
+
12
+ * the paired outcome per round is binary — did the category's policy
13
+ regeneration PROMOTE this cycle (evidence of life) or not;
14
+ * ``p0`` (the null "still improvable" rate) is calibrated from the first
15
+ ``calibration`` rounds and floored at 0.5, so a dead category still
16
+ alarms — just later (~8 betting rounds at lambda=0.5);
17
+ * each non-promoting round multiplies the wealth by
18
+ ``1 - lam + lam * (1 - x) / (1 - p0)`` (> 1 on a miss, < 1 on a
19
+ promotion — promotions crash the wealth, evidence of life resets
20
+ progress); each factor is positive, so the test is anytime-valid at
21
+ level ``alpha`` with threshold ``1/alpha``;
22
+ * "what to output" is trivial here: the loop never discards the incumbent
23
+ on freezing — the last PROMOTED policy stays active and only
24
+ regeneration spending stops.
25
+
26
+ Stdlib only. The stopper is in-memory runtime state; freezing is an
27
+ economic decision, not a correctness surface.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from typing import Any, Dict, Optional
32
+
33
+
34
+ class EvolutionStopper:
35
+ """Per-category wealth process over promotion outcomes."""
36
+
37
+ def __init__(self, alpha: float = 0.05, lam: float = 0.5,
38
+ calibration: int = 3, min_rounds: int = 4,
39
+ threshold: Optional[float] = None) -> None:
40
+ self.alpha = float(alpha)
41
+ self.lam = float(min(max(float(lam), 0.05), 0.95))
42
+ self.calibration = max(int(calibration), 1)
43
+ self.min_rounds = max(int(min_rounds), 1)
44
+ self.threshold = float(threshold if threshold is not None
45
+ else 1.0 / self.alpha)
46
+ self._state: Dict[str, Dict[str, Any]] = {}
47
+
48
+ def observe(self, category: str, promoted: bool) -> Dict[str, Any]:
49
+ """Record one round (cycle) for ``category``; return the decision."""
50
+ st = self._state.setdefault(category, {
51
+ "rounds": 0, "history": [], "wealth": 1.0,
52
+ "p0": None, "frozen": False, "alarm_round": None,
53
+ })
54
+ if st["frozen"]:
55
+ return {"frozen": True, "wealth": st["wealth"], "p0": st["p0"],
56
+ "rounds": st["rounds"], "alarm_round": st["alarm_round"]}
57
+ st["rounds"] += 1
58
+ x = 1 if promoted else 0
59
+ st["history"].append(x)
60
+ if st["p0"] is None:
61
+ # calibration window: no betting yet
62
+ if len(st["history"]) >= self.calibration:
63
+ rate = sum(st["history"]) / len(st["history"])
64
+ # conservative floor: a never-promoting category still
65
+ # detects as dead, just slower (f(0) = 1.5 per miss);
66
+ # cap at 0.99 — a perfect calibration window means the
67
+ # first miss is massive evidence (f(0) = 50.5), not inf
68
+ st["p0"] = min(max(rate, 0.5), 0.99)
69
+ else:
70
+ factor = 1.0 - self.lam + self.lam * ((1.0 - x) / (1.0 - st["p0"]))
71
+ st["wealth"] *= factor
72
+ if (st["rounds"] >= self.min_rounds
73
+ and st["wealth"] >= self.threshold):
74
+ st["frozen"] = True
75
+ st["alarm_round"] = st["rounds"]
76
+ return {"frozen": st["frozen"], "wealth": st["wealth"],
77
+ "p0": st["p0"], "rounds": st["rounds"],
78
+ "alarm_round": st["alarm_round"]}
79
+
80
+ def is_frozen(self, category: str) -> bool:
81
+ st = self._state.get(category)
82
+ return bool(st and st["frozen"])
83
+
84
+ def reset(self, category: Optional[str] = None) -> None:
85
+ if category is None:
86
+ self._state.clear()
87
+ else:
88
+ self._state.pop(category, None)
@@ -38,6 +38,7 @@ from open_dream_rsi.core.curator import (
38
38
  select_lessons,
39
39
  )
40
40
  from open_dream_rsi.core.dreamer import DreamEngine
41
+ from open_dream_rsi.evolution import EvolutionStopper
41
42
  from open_dream_rsi.core.policygen import (
42
43
  PolicyGenerator,
43
44
  PolicySandbox,
@@ -206,7 +207,9 @@ class AutoRSIRuntime:
206
207
  explore_epsilon: float = 0.15,
207
208
  rng_seed: int = 0,
208
209
  enable_judge: bool = True,
210
+ fair_queue: bool = True,
209
211
  sentinel_engine: Any = None,
212
+ enable_evolution_stop: bool = False,
210
213
  alm: Optional["ArtifactLifecycleManager"] = None,
211
214
  ):
212
215
  self.client = client
@@ -231,6 +234,12 @@ class AutoRSIRuntime:
231
234
  self.enable_judge = enable_judge
232
235
  self._judge = LLMJudge(client, max_tokens=256) if enable_judge else None
233
236
  self._rng = random.Random(rng_seed)
237
+ # Fair queue: order the task list by processed-attempt count (least
238
+ # processed first) so a budget-capped cycle can never starve the
239
+ # tail of the backlog — starvation is fixed by PROCESSING, and the
240
+ # prune in dream_once stays what it is: a backstop, not the fix.
241
+ # Benchmarks pin this OFF so published arm numbers stay byte-stable.
242
+ self.fair_queue = fair_queue
234
243
  # Section-3 "dreaming with code": the LLM rewrites the exploration
235
244
  # policy itself; promoted candidates steer tree expansion online.
236
245
  self.enable_policy_code = enable_policy_code
@@ -257,6 +266,13 @@ class AutoRSIRuntime:
257
266
  # episode's stop cause is recorded (node.termination_reason).
258
267
  # Default None = trees byte-identical to pre-observation runs.
259
268
  self.sentinel = sentinel_engine
269
+ # Anytime-valid evolution stopping (opt-in; arXiv:2610.04756): a
270
+ # betting martingale per category over "did regeneration promote
271
+ # this cycle". After an alarm the loop keeps the last promoted
272
+ # policy and stops spending calls on regeneration attempts.
273
+ self.enable_evolution_stop = enable_evolution_stop
274
+ self._evolution = EvolutionStopper()
275
+ self._cycle_policy_promoted: set = set()
260
276
  # Artifact Lifecycle Manager (issue #3): the loop OWNS learning —
261
277
  # what to create and whether a promotion is earned — the ALM OWNS
262
278
  # lifecycle bookkeeping (validation, activation events, staleness,
@@ -422,18 +438,40 @@ class AutoRSIRuntime:
422
438
  report = CycleReport()
423
439
  self._drain_staging(report) # gate leftovers from previous cycles
424
440
  tasks = self._tasks() if callable(self._tasks) else list(self._tasks)
441
+ if self.fair_queue and len(tasks) > 1:
442
+ # Least-processed first (stable: ties keep queue order) so a
443
+ # budget-capped cycle rotates through the backlog instead of
444
+ # always dying at the same head — starvation is fixed by
445
+ # PROCESSING; the queue cap downstream is only a backstop.
446
+ attempts = self.memory.get_all_task_attempts()
447
+ tasks = sorted(tasks, key=lambda t: attempts.get(t.task_id, 0))
425
448
  self._emit("cycle_start", tasks=[t.task_id for t in tasks])
449
+ attempted_categories: set = set()
426
450
  for task in tasks:
427
451
  if self.api_calls_used >= self.api_call_budget:
428
452
  report.stopped_reason = "api_budget_exhausted"
429
453
  break
454
+ attempted_categories.add(task.category)
430
455
  solved, improved = self._work_on_task(task, report)
456
+ self.memory.record_task_attempt(task.task_id)
431
457
  report.tasks_attempted += 1
432
458
  if solved:
433
459
  report.tasks_solved += 1
434
460
  if improved:
435
461
  report.improvements.append(improved)
436
462
 
463
+ if self.enable_evolution_stop:
464
+ for cat in sorted(attempted_categories):
465
+ prev = self._evolution.is_frozen(cat)
466
+ d = self._evolution.observe(
467
+ cat, promoted=cat in self._cycle_policy_promoted)
468
+ if d["frozen"] and not prev:
469
+ self.memory.log_event(
470
+ "evolution_alarm", category=cat, rounds=d["rounds"],
471
+ wealth=round(d["wealth"], 4), p0=d["p0"])
472
+ self._emit("evolution_alarm", category=cat,
473
+ rounds=d["rounds"])
474
+ self._cycle_policy_promoted.clear()
437
475
  self.memory.log_event("cycle", **report.to_dict())
438
476
  self._emit("cycle_done", **report.to_dict())
439
477
  self._alm_maintenance()
@@ -475,8 +513,12 @@ class AutoRSIRuntime:
475
513
  continue
476
514
  verdict = headroom_verdict(self.memory.get_task_outcomes(cat))
477
515
  if not verdict["has_headroom"]:
516
+ # verdict carries its own ``reason``; fold it into a field
517
+ # that cannot collide with the log_event kwarg (TypeError).
518
+ v = dict(verdict)
519
+ v["headroom_reason"] = v.pop("reason", "")
478
520
  self.memory.log_event("lesson_gate_skipped", category=cat,
479
- reason="no_headroom", **verdict)
521
+ reason="no_headroom", **v)
480
522
  continue
481
523
  self._gate_staging_lessons(task, staged, report)
482
524
 
@@ -896,6 +938,8 @@ class AutoRSIRuntime:
896
938
  """Section-3 step: LLM rewrites the exploration policy, gate promotes on evidence."""
897
939
  if not self.enable_policy_code or self.api_calls_used >= self.api_call_budget:
898
940
  return
941
+ if self.enable_evolution_stop and self._evolution.is_frozen(task.category):
942
+ return
899
943
  entry = self.memory.get_policy_code(task.category)
900
944
  if solved and entry:
901
945
  # the category already solves itself — exploration calls are for
@@ -944,6 +988,7 @@ class AutoRSIRuntime:
944
988
  return
945
989
  if self.memory.save_policy_code(task.category, result.source, result.score,
946
990
  steps=steps):
991
+ self._cycle_policy_promoted.add(task.category)
947
992
  report.improvements.append(
948
993
  f"{task.category}: new exploration policy (replay {result.score:.3f})")
949
994
  self.memory.log_event("policy_promoted", task_id=task.task_id,
@@ -977,8 +1022,10 @@ class AutoRSIRuntime:
977
1022
  if not verdict["has_headroom"]:
978
1023
  # v3-v9 measured: no injection mechanism beats a cheap cold
979
1024
  # baseline — do not spend a curator or gate call on this category.
1025
+ v = dict(verdict)
1026
+ v["headroom_reason"] = v.pop("reason", "")
980
1027
  self.memory.log_event("lesson_gate_skipped", category=task.category,
981
- reason="no_headroom", **verdict)
1028
+ reason="no_headroom", **v)
982
1029
  return
983
1030
  existing = self.memory.get_lessons(task.category)
984
1031
  seen_evidence = set(self.memory.get_digested(task.category))
@@ -28,7 +28,7 @@ import json
28
28
  import os
29
29
  import sys
30
30
  from pathlib import Path
31
- from typing import Any, Callable, Dict, List, Optional
31
+ from typing import Any, Dict, List, Optional
32
32
 
33
33
  PROTOCOL_VERSION = "2025-06-18"
34
34
  SERVER_NAME = "open-dream-rsi"
@@ -194,7 +194,7 @@ def tool_odr_run_once(args: Dict[str, Any]) -> Dict[str, Any]:
194
194
  memory=DreamMemory(_memory_root(args)),
195
195
  tasks=tasks,
196
196
  api_call_budget=int(args.get("budget", 10)),
197
- max_tokens=int(args.get("max_tokens", 4096)),
197
+ max_tokens=int(args.get("max_tokens", 100_000)),
198
198
  # Thought-conditioned branching ships ON (library default): attempts
199
199
  # record their PLAN line, proposals see the tried-idea ledger, and
200
200
  # expansion leaves dead idea families. Harnesses can opt out.
@@ -236,7 +236,7 @@ def tool_odr_dream(args: Dict[str, Any]) -> Dict[str, Any]:
236
236
  skills_out=args.get("skills_out") or os.environ.get("ODR_SKILLS_OUT"),
237
237
  client=client,
238
238
  budget=int(args.get("budget", 20)),
239
- max_tokens=int(args.get("max_tokens", 4096)),
239
+ max_tokens=int(args.get("max_tokens", 100_000)),
240
240
  category=args.get("category"),
241
241
  min_new_evidence=int(args.get("min_new_evidence", 3)),
242
242
  max_age_seconds=float(args.get("max_age_seconds", 6 * 3600)),
@@ -305,7 +305,7 @@ TOOLS: Dict[str, Dict[str, Any]] = {
305
305
  "thoughts": {"type": "boolean",
306
306
  "description": "thought-conditioned branching (default true)"},
307
307
  "max_tokens": {"type": "integer",
308
- "description": "completion budget per LLM call (default 4096; "
308
+ "description": "completion budget per LLM call (default 100000; "
309
309
  "raise for reasoning models)"},
310
310
  "timeout": {"type": "number",
311
311
  "description": "seconds per LLM call (default 300; slow local "
@@ -149,6 +149,25 @@ class DreamMemory:
149
149
  data = self._load(self.root / "task_outcomes.json", {})
150
150
  return list(data.get(category, []))
151
151
 
152
+ # -- per-task attempt counters (queue fairness) --------------------------------
153
+
154
+ def record_task_attempt(self, task_id: str) -> None:
155
+ """Count one processed cycle for a task. The fairness ordering in
156
+ ``run_once`` reads these so a task that never got its turn moves to
157
+ the FRONT of the next queue — backlog starvation is fixed by
158
+ processing, not by pruning."""
159
+ data = self._load(self.root / "task_attempts.json", {})
160
+ data[task_id] = int(data.get(task_id, 0)) + 1
161
+ self._save(self.root / "task_attempts.json", data)
162
+
163
+ def get_task_attempts(self, task_id: str) -> int:
164
+ data = self._load(self.root / "task_attempts.json", {})
165
+ return int(data.get(task_id, 0))
166
+
167
+ def get_all_task_attempts(self) -> Dict[str, int]:
168
+ """Whole attempt-count map (one file read for a whole cycle)."""
169
+ return dict(self._load(self.root / "task_attempts.json", {}))
170
+
152
171
  # -- digested failure evidence (survives lesson pruning) ----------------------
153
172
 
154
173
  def get_digested(self, category: str) -> List[str]:
@@ -0,0 +1 @@
1
+ Marking this package as providing inline type information (PEP 561).
@@ -21,6 +21,7 @@ container) so the OS enforces what the AST gate cannot.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import logging
24
25
  import os
25
26
  import shlex
26
27
  import signal
@@ -128,6 +129,31 @@ def jail_prefix() -> List[str]:
128
129
  return shlex.split(raw) if raw else []
129
130
 
130
131
 
132
+ #: Set once the no-jail warning has been logged in this process.
133
+ _JAIL_WARNED = False
134
+
135
+
136
+ def _warn_no_jail_once() -> None:
137
+ """Log a one-time warning when generated code runs without a jail.
138
+
139
+ ``python -I`` plus rlimits is defence in depth only: it does NOT restrict
140
+ filesystem or network access. Deployments that ingest hostile task
141
+ sources should set ``ODR_SANDBOX_CMD`` or containerise the loop (see
142
+ ``SECURITY.md``).
143
+ """
144
+ global _JAIL_WARNED
145
+ if _JAIL_WARNED or jail_prefix():
146
+ return
147
+ _JAIL_WARNED = True
148
+ logging.getLogger(__name__).warning(
149
+ "ODR_SANDBOX_CMD is unset: generated code runs with process isolation "
150
+ "only (python -I, rlimits, process-group kill) and NO filesystem or "
151
+ "network restriction. For hostile task sources set ODR_SANDBOX_CMD "
152
+ "(e.g. 'bwrap --unshare-all --die-with-parent --ro-bind / /') or run "
153
+ "the loop inside a container - see SECURITY.md."
154
+ )
155
+
156
+
131
157
  def run_isolated(
132
158
  argv: List[str],
133
159
  *,
@@ -143,7 +169,12 @@ def run_isolated(
143
169
 
144
170
  Raises the same :class:`subprocess.TimeoutExpired` as ``subprocess.run``
145
171
  so callers keep their existing handling.
172
+
173
+ POSIX gets the full wall (rlimits + whole-process-group kill); on Windows
174
+ there are no rlimits and the timeout kills only the direct child — treat
175
+ Windows as out of scope for hostile task sources.
146
176
  """
177
+ _warn_no_jail_once()
147
178
  argv = [*jail_prefix(), *argv]
148
179
  env = SANDBOX_ENV if env is None else env
149
180
  kwargs = {}
@@ -31,7 +31,7 @@ import subprocess
31
31
  import time
32
32
  from dataclasses import dataclass, field
33
33
  from pathlib import Path
34
- from typing import Any, Dict, List, Optional, Tuple
34
+ from typing import Any, Dict, List, Optional
35
35
 
36
36
  from open_dream_rsi.sessions import redact_secrets
37
37
 
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from dataclasses import dataclass, field
28
28
  from pathlib import Path
29
- from typing import Any, Dict, List, Optional
29
+ from typing import Any, Dict, Optional
30
30
 
31
31
  #: Env var that overrides the goose config dir (useful in tests / CI).
32
32
  CONFIG_DIR_ENV = "ODR_GOOSE_CONFIG_DIR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-open-dream-rsi",
3
- "version": "0.3.1",
3
+ "version": "0.3.8",
4
4
  "description": "Let your coding agent dream: strategies must survive counterfactual replay before they earn trust. An offline self-improvement loop over an evolving world model — no fine-tuning, the LLM stays frozen. Speaks MCP. Ships its Python server inside the package — the only requirement is a system python3 (3.10+) on PATH — and the plugin's bridge serves the dreamer's LLM calls from the model your harness is already configured with (no API keys, no env, no extra endpoints).",
5
5
  "type": "module",
6
6
  "license": "MIT",