dsh-open-dream-rsi 0.2.3 → 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 +23 -2
- package/open_dream_rsi/__init__.py +1 -1
- package/open_dream_rsi/bench.py +5 -2
- package/open_dream_rsi/bench_policy.py +104 -7
- package/open_dream_rsi/cli.py +33 -8
- package/open_dream_rsi/core/agent.py +1 -1
- package/open_dream_rsi/core/curator.py +26 -11
- package/open_dream_rsi/core/policygen.py +41 -5
- package/open_dream_rsi/corrections.py +213 -0
- package/open_dream_rsi/cursor_agent.py +1 -1
- package/open_dream_rsi/dream.py +71 -5
- package/open_dream_rsi/evolution.py +88 -0
- package/open_dream_rsi/loop.py +49 -2
- package/open_dream_rsi/mcp.py +4 -4
- package/open_dream_rsi/memory.py +19 -0
- package/open_dream_rsi/py.typed +1 -0
- package/open_dream_rsi/sandbox.py +31 -0
- package/open_dream_rsi/share.py +1 -1
- package/open_dream_rsi/utils/goose.py +1 -1
- package/package.json +1 -1
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.
|
|
44
|
-
dsh plugin --profile <name> add /path/to/dsh-open-dream-rsi-0.
|
|
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):
|
package/open_dream_rsi/bench.py
CHANGED
|
@@ -19,8 +19,8 @@ import json
|
|
|
19
19
|
import shutil
|
|
20
20
|
import tempfile
|
|
21
21
|
import time
|
|
22
|
-
from dataclasses import asdict, dataclass
|
|
23
|
-
from typing import Any,
|
|
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,
|
|
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,
|
|
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 =
|
|
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"
|
|
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'(
|
|
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
|
|
package/open_dream_rsi/cli.py
CHANGED
|
@@ -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,
|
|
@@ -147,31 +148,49 @@ def cmd_sessions(args: argparse.Namespace) -> int:
|
|
|
147
148
|
memory = DreamMemory(args.memory)
|
|
148
149
|
provider = getattr(args, "provider", "openai")
|
|
149
150
|
if args.dry_run:
|
|
151
|
+
from open_dream_rsi.corrections import user_corrections
|
|
150
152
|
for ep in eps[: args.limit or len(eps)]:
|
|
151
153
|
fails = episode_failures(ep)
|
|
154
|
+
corrs = user_corrections(ep)
|
|
152
155
|
print(f"[{ep.source}:{ep.session_id[:12]}] {ep.title[:60]} "
|
|
153
|
-
f"— {len(fails)} failure-shaped record(s)")
|
|
154
|
-
for f in fails[:3]:
|
|
156
|
+
f"— {len(fails)} failure-shaped, {len(corrs)} correction-shaped record(s)")
|
|
157
|
+
for f in (fails + corrs)[:3]:
|
|
155
158
|
print(" ", f["errors"][0][:100])
|
|
156
159
|
return 0
|
|
157
160
|
from open_dream_rsi.cli import _build_client
|
|
158
161
|
client = _build_client(provider, args.model)
|
|
159
162
|
curator = KnowledgeCurator(client)
|
|
163
|
+
from open_dream_rsi.corrections import (
|
|
164
|
+
dedupe_corrections,
|
|
165
|
+
distillable,
|
|
166
|
+
user_corrections,
|
|
167
|
+
)
|
|
160
168
|
total_added = 0
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
ep_evidence = [(ep, episode_failures(ep), user_corrections(ep))
|
|
170
|
+
for ep in eps]
|
|
171
|
+
all_corr = distillable(
|
|
172
|
+
dedupe_corrections([c for _, _, cs in ep_evidence for c in cs]))
|
|
173
|
+
kept = {(c.get("session_id"), c.get("msg_index")) for c in all_corr}
|
|
174
|
+
for ep, failures, corr_raw in ep_evidence:
|
|
175
|
+
corrections = [c for c in corr_raw
|
|
176
|
+
if (c.get("session_id"), c.get("msg_index")) in kept]
|
|
177
|
+
if corrections:
|
|
178
|
+
memory.log_event("user_correction_found", source=ep.source,
|
|
179
|
+
session_id=ep.session_id, count=len(corrections),
|
|
180
|
+
classes=[c.get("class", "") for c in corrections][:6])
|
|
181
|
+
evidence = failures + corrections
|
|
182
|
+
if not evidence:
|
|
164
183
|
continue
|
|
165
184
|
category = args.category or (
|
|
166
185
|
Path(ep.cwd).name if ep.cwd else ep.source)
|
|
167
186
|
distilled, err = curator.distill(category, ep.title or category,
|
|
168
|
-
|
|
187
|
+
evidence, memory.get_lessons(category))
|
|
169
188
|
if err or not distilled:
|
|
170
189
|
memory.log_event("lesson_rejected", source=ep.source,
|
|
171
190
|
session_id=ep.session_id, reason=(err or "empty")[:300])
|
|
172
191
|
continue
|
|
173
192
|
result = curate_lessons(memory.get_lessons(category), distilled,
|
|
174
|
-
evidence=evidence_snippets(
|
|
193
|
+
evidence=evidence_snippets(evidence))
|
|
175
194
|
memory.replace_lessons(category, result.entries)
|
|
176
195
|
memory.log_event("lessons_curated", source=ep.source,
|
|
177
196
|
session_id=ep.session_id, category=category,
|
|
@@ -336,6 +355,8 @@ def cmd_artifacts(args: argparse.Namespace) -> int:
|
|
|
336
355
|
def main(argv: list[str] | None = None) -> int:
|
|
337
356
|
parser = argparse.ArgumentParser(prog="odr", description="Open Dream-RSI autonomous supervisor")
|
|
338
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}")
|
|
339
360
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
340
361
|
|
|
341
362
|
loop = sub.add_parser("loop", help="run the self-improvement cycle")
|
|
@@ -351,6 +372,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
351
372
|
loop.add_argument("--dream-iters", type=int, default=60)
|
|
352
373
|
loop.add_argument("--no-policy-code", action="store_true",
|
|
353
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)")
|
|
354
379
|
loop.add_argument("--no-knowledge", action="store_true",
|
|
355
380
|
help="disable the knowledge curator (lessons.json KB, section-4 step)")
|
|
356
381
|
loop.add_argument("--no-thoughts", action="store_true",
|
|
@@ -389,7 +414,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
389
414
|
help="force one KB category (default: per-episode cwd)")
|
|
390
415
|
dr.add_argument("--budget", type=int, default=20,
|
|
391
416
|
help="max API calls for a FULL dream (default 20)")
|
|
392
|
-
dr.add_argument("--max-tokens", type=int, default=
|
|
417
|
+
dr.add_argument("--max-tokens", type=int, default=100_000)
|
|
393
418
|
dr.add_argument("--offline", action="store_true",
|
|
394
419
|
help="no LLM: lifecycle maintenance only")
|
|
395
420
|
dr.set_defaults(func=cmd_dream)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import
|
|
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 (<=
|
|
27
|
-
failure evidence shown to the curator
|
|
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 =
|
|
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 =
|
|
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
|
|
88
|
-
"grounded ONLY in the evidence shown.
|
|
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')
|
|
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 =
|
|
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
|
|
@@ -397,11 +407,16 @@ class KnowledgeCurator:
|
|
|
397
407
|
known = "\n".join(f" - [{l.get('trigger', '')}] {l.get('text', '')}"
|
|
398
408
|
for l in existing[-MAX_LESSONS_PER_CATEGORY:]) or "(empty KB)"
|
|
399
409
|
feedback = ""
|
|
410
|
+
has_corr = any("user said:" in e for e in evidence)
|
|
400
411
|
for attempt in range(1 + self.max_repair):
|
|
401
412
|
user = (f"Category [{category}]: {task_prompt}\n\n"
|
|
402
413
|
f"Lessons already in the knowledge base (do not restate):\n{known}\n\n"
|
|
403
|
-
f"Failed attempts this cycle:\n" + "\n".join(evidence) +
|
|
404
|
-
|
|
414
|
+
f"Failed attempts this cycle:\n" + "\n".join(evidence) +
|
|
415
|
+
("\n\nNote: some records are user corrections (action starts with "
|
|
416
|
+
"'user_correction'): the user's wording is evidence worth testing, "
|
|
417
|
+
"not verified truth; distil only the reusable, scoped rule."
|
|
418
|
+
if has_corr else "") +
|
|
419
|
+
f"\n\nPrevious rejection reason: {feedback or '(none)'}\n\n"
|
|
405
420
|
f"Return the JSON array of at most {self.max_lessons} lessons.")
|
|
406
421
|
kwargs: Dict[str, Any] = {"temperature": 0.5} # distillation, not creativity
|
|
407
422
|
kwargs["max_tokens"] = self.max_tokens
|
|
@@ -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 +
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""User-correction extraction (issue #6): natural-language evidence.
|
|
2
|
+
|
|
3
|
+
Host sessions contain corrections the user states in prose ("nie tak,
|
|
4
|
+
użyj istniejącego klienta", "don't create another client") — invisible to
|
|
5
|
+
:func:`open_dream_rsi.sessions.episode_failures`, which scans tool output
|
|
6
|
+
for error markers. This module turns such turns into evidence records
|
|
7
|
+
shaped like failure records (``action`` / ``score`` / ``errors``) so the
|
|
8
|
+
existing curator and replay-gate flow consumes them without a second
|
|
9
|
+
pipeline, while keeping the ``user_correction`` evidence kind and
|
|
10
|
+
provenance recoverable (README § Verdicts must be replayable).
|
|
11
|
+
|
|
12
|
+
Trust model is unchanged: a user correction is *evidence worth testing*,
|
|
13
|
+
not verified truth. Nothing here can make a lesson ACTIVE — candidates
|
|
14
|
+
enter staging like every other lesson and face the same paired-replay
|
|
15
|
+
gate. Extraction is deterministic and deliberately conservative: only
|
|
16
|
+
obvious, well-scoped candidates are kept; ambiguous negatives, ordinary
|
|
17
|
+
follow-ups and task expansions stay unclassified.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
__all__ = ["classify_user_message", "user_corrections", "dedupe_corrections",
|
|
24
|
+
"prioritize_corrections", "distillable"]
|
|
25
|
+
|
|
26
|
+
#: Turns that reject a result or approach ("Correction" / "Approach
|
|
27
|
+
#: redirect" in issue #6). Kept tight on purpose — a broad "nie|not" net
|
|
28
|
+
#: would swallow ordinary follow-ups (the measured v3 lesson: trust is
|
|
29
|
+
#: earned per class, never by breadth).
|
|
30
|
+
_CORRECTION_HINTS = re.compile(
|
|
31
|
+
r"\b(?:nie\s+tak|nie\s+o\s+to|nie\s+chodzi\s+o|chodzi\s+mi\s+o|"
|
|
32
|
+
r"źle|błędnie|błędne|błędn|złe\s+założen|przecież|zamiast|"
|
|
33
|
+
r"nadpis|nie\s+wolno|nie\s+rób|nie\s+używaj|nie\s+twórz|"
|
|
34
|
+
r"weź\s+pod\s+uwagę|uwzględnij|użyj\s+istniejąc|skorzystaj\s+z\s+istniejąc|"
|
|
35
|
+
r"jak\s+(?:napisałem|mówiłem|prosiłem)|już\s+(?:pisałem|mówiłem|prosiłem)|"
|
|
36
|
+
r"znowu\s+(?:to\s+samo|piszesz|robisz)|"
|
|
37
|
+
r"\bwrong\b|\bincorrectly?\b|that'?s\s+not|that\s+is\s+not|instead\s+of|"
|
|
38
|
+
r"don't\s+(?:do|create|use|touch|add|overwrite)|do\s+not\s+(?:do|create|use|touch|add|overwrite)|"
|
|
39
|
+
r"reuse\s+(?:the\s+)?(?:existing|shared)|as\s+i\s+(?:said|wrote|told|asked)|"
|
|
40
|
+
r"we\s+already\s+(?:tried|did|fixed)|\bbreaks\b|\bbroke\b|stop\s+(?:doing|touching|creating)"
|
|
41
|
+
r")", re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
#: Stated conventions ("Preference" in issue #6). Legitimate scoped-rule
|
|
44
|
+
#: evidence, but never framed as a failure lesson by the caller.
|
|
45
|
+
_PREFERENCE_HINTS = re.compile(
|
|
46
|
+
r"\b(?:preferuję|wolę|zawsze\s+(?:używaj|pisz|rób|dziel)|"
|
|
47
|
+
r"nigdy\s+(?:nie\s+)?(?:używaj|pisz|rób)|trzymaj\s+się\s+konwencji|"
|
|
48
|
+
r"\bprefer\b|\bi\s+prefer\b|\bi\s+usually\b|always\s+(?:use|write|split)|"
|
|
49
|
+
r"never\s+(?:use|do|commit)|convention\s+is\b)", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
#: Task expansion / new requirement ("New requirement or clarification").
|
|
52
|
+
#: Extracted for completeness (the classes must be distinguishable) but
|
|
53
|
+
#: NOT distillation-ready by default: it is not evidence of a mistake.
|
|
54
|
+
_REQUIREMENT_HINTS = re.compile(
|
|
55
|
+
r"\b(?:dodaj\s+jeszcze|dołóż|także\s+|poza\s+tym|oprócz\s+tego|"
|
|
56
|
+
r"a\s+co\s+z\s+|możesz\s+też|przy\s+okazji|"
|
|
57
|
+
r"what\s+about\b|also\s+add|in\s+addition|one\s+more\s+thing|"
|
|
58
|
+
r"while\s+you'?re\s+at\s+it)", re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
#: Cheap ambiguity guards: short reactions, thanks, questions and bare
|
|
61
|
+
#: acknowledgements are never promoted (issue §1: reject or leave
|
|
62
|
+
#: unclassified rather than promote).
|
|
63
|
+
_AMBIGUITY = re.compile(
|
|
64
|
+
r"^\s*(?:no|no,|nope|ok|okej|okay|super|spoko|git|dobrze|dziękuję|dzieki|thanks|"
|
|
65
|
+
r"thank\s+you|thx|why|czemu|dlaczego|ale\b)[.!?,\s]|(\?)")
|
|
66
|
+
|
|
67
|
+
#: Assistant replies that suggest the session recovered after the
|
|
68
|
+
#: correction ("subsequent outcome supports the correction").
|
|
69
|
+
_OUTCOME_MARKERS = ("działa", "dziala", "zrobione", "gotowe", "naprawione",
|
|
70
|
+
"passes", "passed", "fixed", "works now", "solved")
|
|
71
|
+
|
|
72
|
+
_MIN_TEXT = 25
|
|
73
|
+
_MAX_RECORDS = 6
|
|
74
|
+
_MAX_TEXT = 220
|
|
75
|
+
|
|
76
|
+
_STOPWORDS = {
|
|
77
|
+
"this", "that", "with", "have", "jest", "nie", "jak", "dla", "ale",
|
|
78
|
+
"się", "sie", "tym", "tego", "który", "ktore", "ktora", "jestem",
|
|
79
|
+
"made", "make", "want", "need", "please", "prosze", "proszę", "the",
|
|
80
|
+
"and", "you", "your", "have", "that", "this", "with", "dont", "don't",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def classify_user_message(text: str) -> Optional[str]:
|
|
85
|
+
"""Classify one user turn: ``correction`` | ``approach_redirect`` |
|
|
86
|
+
``preference`` | ``new_requirement`` | ``None`` (not a candidate).
|
|
87
|
+
|
|
88
|
+
``correction`` wins over ``approach_redirect`` when both fire — a
|
|
89
|
+
rejection with a proposed alternative is recorded as the correction
|
|
90
|
+
it is; the redirect nuance lives in the text, not the label.
|
|
91
|
+
"""
|
|
92
|
+
t = (text or "").strip()
|
|
93
|
+
if len(t) < _MIN_TEXT or _AMBIGUITY.search(t[:24] or t):
|
|
94
|
+
return None
|
|
95
|
+
corr = _CORRECTION_HINTS.search(t)
|
|
96
|
+
pref = _PREFERENCE_HINTS.search(t)
|
|
97
|
+
# Earliest explicit signal wins: "Preferuję małe commity zamiast jednego
|
|
98
|
+
# dużego" states a preference — the comparative "zamiast" is not a
|
|
99
|
+
# rejection — while "Nie twórz X, zamiast tego użyj Y" leads with the
|
|
100
|
+
# rejection. Ties resolve to correction (the class the gate and priority
|
|
101
|
+
# ranking are calibrated for).
|
|
102
|
+
if pref and (not corr or pref.start() < corr.start()):
|
|
103
|
+
return "preference"
|
|
104
|
+
if corr:
|
|
105
|
+
return "correction"
|
|
106
|
+
if _REQUIREMENT_HINTS.search(t):
|
|
107
|
+
return "new_requirement"
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def user_corrections(ep: Any, max_items: int = _MAX_RECORDS) -> List[Dict[str, Any]]:
|
|
112
|
+
"""Extract user-correction evidence records from one episode.
|
|
113
|
+
|
|
114
|
+
Each record pairs the correction with the assistant turn it targets
|
|
115
|
+
(nearest preceding assistant message, tool rows skipped) and carries
|
|
116
|
+
provenance (source host, session id, workspace, message index) plus
|
|
117
|
+
``outcome_supported`` — whether the next assistant reply suggests the
|
|
118
|
+
session recovered after the correction. Message text arrives already
|
|
119
|
+
secret-redacted from the readers; nothing is added back.
|
|
120
|
+
"""
|
|
121
|
+
records: List[Dict[str, Any]] = []
|
|
122
|
+
msgs = list(getattr(ep, "messages", []))
|
|
123
|
+
for i, m in enumerate(msgs):
|
|
124
|
+
if getattr(m, "role", "") != "user":
|
|
125
|
+
continue
|
|
126
|
+
cls = classify_user_message(m.text or "")
|
|
127
|
+
if cls is None:
|
|
128
|
+
continue
|
|
129
|
+
assistant_text = ""
|
|
130
|
+
for j in range(i - 1, -1, -1):
|
|
131
|
+
if msgs[j].role == "assistant":
|
|
132
|
+
assistant_text = msgs[j].text or ""
|
|
133
|
+
break
|
|
134
|
+
outcome = False
|
|
135
|
+
for j in range(i + 1, len(msgs)):
|
|
136
|
+
if msgs[j].role == "assistant":
|
|
137
|
+
low = (msgs[j].text or "").lower()
|
|
138
|
+
outcome = any(k in low for k in _OUTCOME_MARKERS)
|
|
139
|
+
break
|
|
140
|
+
records.append({
|
|
141
|
+
"action": f"user_correction:{getattr(ep, 'source', '')}:"
|
|
142
|
+
f"{str(getattr(ep, 'session_id', ''))[:12]}#{i}",
|
|
143
|
+
"score": 0.0,
|
|
144
|
+
"errors": [
|
|
145
|
+
f"user said: {(m.text or '')[:_MAX_TEXT]}",
|
|
146
|
+
f"assistant had: {assistant_text[:_MAX_TEXT]}",
|
|
147
|
+
f"outcome: {'confirmed' if outcome else 'unverified'}",
|
|
148
|
+
],
|
|
149
|
+
"kind": "user_correction",
|
|
150
|
+
"class": cls,
|
|
151
|
+
"source": getattr(ep, "source", ""),
|
|
152
|
+
"session_id": getattr(ep, "session_id", ""),
|
|
153
|
+
"cwd": getattr(ep, "cwd", ""),
|
|
154
|
+
"msg_index": i,
|
|
155
|
+
"outcome_supported": outcome,
|
|
156
|
+
"repeats": 1,
|
|
157
|
+
"occurrences": 1,
|
|
158
|
+
})
|
|
159
|
+
return records[-max_items:]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _content_words(text: str) -> frozenset:
|
|
163
|
+
words = re.findall(r"[a-ząćęłńóśźż]{4,}", (text or "").lower())
|
|
164
|
+
return frozenset(w for w in words if w not in _STOPWORDS)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dedupe_corrections(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
168
|
+
"""Collapse near-identical corrections before any paid curator call.
|
|
169
|
+
|
|
170
|
+
Two records merge when their content words overlap (Jaccard >= 0.6).
|
|
171
|
+
Repeats inside ONE episode do not strengthen the evidence: ``repeats``
|
|
172
|
+
counts distinct episodes behind the observation, ``occurrences`` the
|
|
173
|
+
raw message count (issue §3).
|
|
174
|
+
"""
|
|
175
|
+
kept: List[Dict[str, Any]] = []
|
|
176
|
+
sigs: List[frozenset] = []
|
|
177
|
+
for rec in records:
|
|
178
|
+
sig = _content_words(" ".join(rec.get("errors", [])))
|
|
179
|
+
merged = False
|
|
180
|
+
for k, (old, old_sig) in enumerate(zip(kept, sigs)):
|
|
181
|
+
if old_sig and sig and (len(sig & old_sig) / len(sig | old_sig)) >= 0.6:
|
|
182
|
+
old["occurrences"] += 1
|
|
183
|
+
if old.get("session_id") != rec.get("session_id"):
|
|
184
|
+
old["repeats"] += 1
|
|
185
|
+
if rec.get("outcome_supported") and not old.get("outcome_supported"):
|
|
186
|
+
old["outcome_supported"] = True
|
|
187
|
+
old["errors"] = list(old["errors"])[:2] + ["outcome: confirmed"]
|
|
188
|
+
merged = True
|
|
189
|
+
break
|
|
190
|
+
if not merged:
|
|
191
|
+
kept.append(rec)
|
|
192
|
+
sigs.append(sig)
|
|
193
|
+
return kept
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def prioritize_corrections(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
197
|
+
"""Highest-priority first (issue §3): confirmed outcome > repeated
|
|
198
|
+
across episodes > bare correction > preference. Stable sort."""
|
|
199
|
+
def rank(r: Dict[str, Any]) -> tuple:
|
|
200
|
+
return (
|
|
201
|
+
3 if r.get("outcome_supported") else 0,
|
|
202
|
+
2 if r.get("repeats", 1) >= 2 else 0,
|
|
203
|
+
1 if r.get("class") == "correction" else 0,
|
|
204
|
+
)
|
|
205
|
+
return sorted(records, key=rank, reverse=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def distillable(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
209
|
+
"""Records eligible for the existing curator flow: corrections,
|
|
210
|
+
redirects and preferences — never new requirements or unclassified
|
|
211
|
+
turns (issue §1/§3: a task expansion is not evidence of a mistake)."""
|
|
212
|
+
return [r for r in records
|
|
213
|
+
if r.get("class") in ("correction", "approach_redirect", "preference")]
|
|
@@ -19,7 +19,7 @@ import os
|
|
|
19
19
|
import shutil
|
|
20
20
|
import subprocess
|
|
21
21
|
import sys
|
|
22
|
-
from typing import
|
|
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.
|
package/open_dream_rsi/dream.py
CHANGED
|
@@ -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,
|
|
@@ -164,6 +204,11 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
|
|
|
164
204
|
evidence_snippets,
|
|
165
205
|
KnowledgeCurator)
|
|
166
206
|
from open_dream_rsi.sessions import episode_failures, read_hermes_sessions
|
|
207
|
+
from open_dream_rsi.corrections import (
|
|
208
|
+
dedupe_corrections,
|
|
209
|
+
distillable,
|
|
210
|
+
user_corrections,
|
|
211
|
+
)
|
|
167
212
|
|
|
168
213
|
root = Path(memory.root)
|
|
169
214
|
state = _load_state(root)
|
|
@@ -191,13 +236,28 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
|
|
|
191
236
|
added_total = 0
|
|
192
237
|
deferred = 0
|
|
193
238
|
probes: List[Dict[str, Any]] = []
|
|
194
|
-
|
|
239
|
+
# Issue #6: user corrections are a second evidence class. Collect per
|
|
240
|
+
# episode, dedupe deterministically ACROSS the batch before any paid
|
|
241
|
+
# curator call, then distil failures + surviving corrections together
|
|
242
|
+
# — one pipeline, one gate, no second learning path.
|
|
243
|
+
ep_evidence = [(ep, episode_failures(ep), user_corrections(ep))
|
|
244
|
+
for ep in new]
|
|
245
|
+
all_corr = distillable(
|
|
246
|
+
dedupe_corrections([c for _, _, cs in ep_evidence for c in cs]))
|
|
247
|
+
kept = {(c.get("session_id"), c.get("msg_index")) for c in all_corr}
|
|
248
|
+
for ep, failures, corr_raw in ep_evidence:
|
|
195
249
|
seen.add(ep.session_id)
|
|
196
|
-
|
|
197
|
-
|
|
250
|
+
corrections = [c for c in corr_raw
|
|
251
|
+
if (c.get("session_id"), c.get("msg_index")) in kept]
|
|
252
|
+
if corrections:
|
|
253
|
+
memory.log_event("user_correction_found", source=ep.source,
|
|
254
|
+
session_id=ep.session_id, count=len(corrections),
|
|
255
|
+
classes=[c.get("class", "") for c in corrections][:6])
|
|
256
|
+
evidence = failures + corrections
|
|
257
|
+
if not evidence:
|
|
198
258
|
continue
|
|
199
259
|
cat = category or (Path(ep.cwd).name if ep.cwd else ep.source)
|
|
200
|
-
distilled, err = curator.distill(cat, ep.title or cat,
|
|
260
|
+
distilled, err = curator.distill(cat, ep.title or cat, evidence,
|
|
201
261
|
memory.get_lessons(cat))
|
|
202
262
|
if err and err.startswith("llm error"):
|
|
203
263
|
# Transport failure (connect/HTTP/response shape): this session
|
|
@@ -242,7 +302,7 @@ def dream_once(memory_root: "str | Path", *,
|
|
|
242
302
|
session_source: str = "hermes",
|
|
243
303
|
skills_out: "str | Path | None" = None,
|
|
244
304
|
client=None, tasks: Optional[List[Any]] = None,
|
|
245
|
-
budget: int = 20, max_tokens: int =
|
|
305
|
+
budget: int = 20, max_tokens: int = 100_000,
|
|
246
306
|
category: Optional[str] = None,
|
|
247
307
|
min_new_evidence: int = DEFAULT_MIN_NEW_EVIDENCE,
|
|
248
308
|
max_age_seconds: float = DEFAULT_MAX_AGE_SECONDS) -> Dict[str, Any]:
|
|
@@ -291,6 +351,12 @@ def dream_once(memory_root: "str | Path", *,
|
|
|
291
351
|
state["last_full_dream"] = time.time()
|
|
292
352
|
_save_state(root, state)
|
|
293
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
|
+
|
|
294
360
|
# 4. publish earned knowledge (cheap; surfaces new ACTIVE lessons)
|
|
295
361
|
if skills_out:
|
|
296
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)
|
package/open_dream_rsi/loop.py
CHANGED
|
@@ -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", **
|
|
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", **
|
|
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))
|
package/open_dream_rsi/mcp.py
CHANGED
|
@@ -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,
|
|
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",
|
|
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",
|
|
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
|
|
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 "
|
package/open_dream_rsi/memory.py
CHANGED
|
@@ -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 = {}
|
package/open_dream_rsi/share.py
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
+
"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",
|