diff --git a/CHANGELOG.md b/CHANGELOG.md index d585d5ba..3bee29dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Versions follow [SemVer](https://semver.org). ### Changed +- Attempts start with the target's research memory: the newest reports from the `research-log` branch are inlined in the brief, the full archive is materialized in the channel, and `syscall reports` lists it (one line each) or prints full reports, several per call. Works across clusters: any kernel climbing the target reads the same branch. - Every role can search and read the web (Claude: WebSearch/WebFetch; Codex: `--search`; hermes: the `web` and `search` toolsets) — literature and documentation are part of research. - A park now submits its own wake job, which waits on the park's jobs and starts the moment they finish (previously: the next sweep, a grace diff --git a/docs/roadmap.md b/docs/roadmap.md index f6bb1e2a..8f8a17d1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -161,6 +161,25 @@ open-ended sweep space. - [ ] Periodic distillation pass: raw reports → bounded lessons/.md - [ ] Weekly digest aggregates per-run reports; cost ledger; leaderboard history +## Cross-cluster (dev plan, 2026-08-29) + +Research findings sync through GitHub; job state never leaves its cluster. + +- [x] Findings memory: every attempt fetches the target's `research-log` + branch — the brief inlines the newest reports, the full archive lands + in the channel, `syscall reports` gives a summary list and full views. + This is the cross-cluster findings sync too: a second cluster's kernel + reads the same branch. +- [ ] Tier 1 (works now): one deployment per cluster, each with its own + state root and tick chain; different targets, coordination through + GitHub. First candidate: EmpireAI as a warm-keeper deployment. +- [ ] Tier 2 (same target from several clusters): per-site agent-slot and + PR-branch namespacing; per-site pacing overrides so N kernels do not + multiply `max_active_attempts`/`runs_per_week`. +- [ ] Non-goal: one kernel driving remote compute (tier 3) — it would need a + file-transport layer and cross-scheduler dependencies for little gain + over tier 2. + ## Phase 7 — Research targets & scale-out - [x] Verification agent (scaling.md Part 2d): verifier on the review diff --git a/src/autoresearch/attempt.py b/src/autoresearch/attempt.py index 26d3f599..f0e82e50 100644 --- a/src/autoresearch/attempt.py +++ b/src/autoresearch/attempt.py @@ -932,6 +932,58 @@ def _launch_refund( return launch_hours_refund(launches, elapsed, gpus=gpus) +RESEARCH_LOG_BRANCH = "research-log" +MAX_ARCHIVED_REPORTS = 30 # materialized for the session to read; newest first +MAX_ARCHIVED_REPORT_CHARS = 100_000 # per report; branch content is remote-controlled + + +def _fetch_research_reports(ws: Workspace, count: int) -> list[tuple[str, str]]: + """The newest `count` reports from the target's research-log branch, as + (name, text), newest first — the shared memory of every attempt on this + target, wherever it ran. Fail-soft: a target with no research log yet + (or an unreachable remote) is an empty memory, never a dead attempt.""" + try: + ws.git_network("fetch", "origin", RESEARCH_LOG_BRANCH) + listing = ws.git("ls-tree", "-r", "--name-only", "FETCH_HEAD", "reports/") + # only direct children (the publisher's layout): a nested path would + # flatten to a basename that overwrites another archived report + names = [ + line.strip() + for line in listing.splitlines() + if line.strip().endswith(".md") and line.strip().count("/") == 1 + ] + # report files are dated (reports/-.md): the name + # sorts by day; same-day order is arbitrary and does not matter + out: list[tuple[str, str]] = [] + for name in sorted(names, reverse=True): + if len(out) >= count: + break + # size BEFORE content: `git show` would load the whole blob, and + # the branch's content is remote-controlled + if int(ws.git("cat-file", "-s", f"FETCH_HEAD:{name}").strip()) > ( + MAX_ARCHIVED_REPORT_CHARS + ): + log.info("research report %s exceeds the size cap; skipped", name) + continue + out.append((Path(name).name, ws.git("show", f"FETCH_HEAD:{name}"))) + return out + except Exception as exc: + log.info("research log unavailable (%s: %s); starting without it", type(exc).__name__, exc) + return [] + + +def _install_report_archive(workspace: Path, reports: list[tuple[str, str]]) -> None: + """Materialize the fetched reports under the kernel-owned channel + (`.autoresearch/reports/`) so the session can read and search the full + texts with its own tools; the brief inlines only the newest few.""" + dest = workspace / SYSCALL_DIR / "reports" + dest.mkdir(parents=True, exist_ok=True) + for name, text in reports: + if Path(name).name != name: # branch content is remote-controlled + continue + (dest / name).write_text(text) + + def _stage_launches(record: RunRecord) -> list[dict]: """The persisted launch descriptors of an author-sleep stage (name + artifacts), tolerating a malformed entry by skipping it (the job dirs are @@ -1876,6 +1928,7 @@ def live_attempt( channel.is_symlink(), ) author_syscalls = False + reports = _fetch_research_reports(ws, MAX_ARCHIVED_REPORTS) if author_syscalls: assert _bench is not None syscall_excluded(workspace) @@ -1891,6 +1944,9 @@ def live_attempt( float(contract.budgets.gpu_hours_per_run) if _bench.gpus else None ), ) + # AFTER install_tool: installing the tool recreates the channel + # dir it owns, which would delete an archive written earlier + _install_report_archive(workspace, reports) def changed_paths() -> list[str]: ws.git("add", "-A") @@ -2019,6 +2075,8 @@ def snapshot() -> str: changed_paths=changed_paths, created=created, task_hypothesis=task_hypothesis, + recent_reports=tuple(text for _name, text in reports), + report_archive=author_syscalls, spec=spec, panel_runner=panel_runner, brief_baseline=prior_best.best if prior_best else None, diff --git a/src/autoresearch/brief.py b/src/autoresearch/brief.py index 2df0e0c4..94aab51b 100644 --- a/src/autoresearch/brief.py +++ b/src/autoresearch/brief.py @@ -71,6 +71,7 @@ class SessionBrief: recent_reports: tuple[str, ...] # newest first, already bounded budget: BudgetState created: str # ISO timestamp, supplied by the caller (builder stays pure) + report_archive: bool = False # the syscall tool + full archive are installed # Author-syscall budgets (research-loop.md, "one syscall"): >0 advertises the # launch/sleep tool to the author; 0 (the default) means the feature is off # for this run and the brief never mentions it. @@ -93,6 +94,7 @@ def from_json(cls, raw: str) -> SessionBrief: ruler=data["ruler"], lessons=data["lessons"], recent_reports=tuple(data["recent_reports"]), + report_archive=bool(data.get("report_archive", False)), budget=BudgetState(**data["budget"]), created=data["created"], launch_budget=data.get("launch_budget", 0), @@ -111,6 +113,7 @@ class BriefInputs: ruler: str lessons: str = "" recent_reports: tuple[str, ...] = field(default_factory=tuple) + report_archive: bool = False # the syscall tool + full archive are installed budget: BudgetState = field(default_factory=lambda: BudgetState(0.0, 0)) launch_budget: int = 0 # author-syscall budgets; 0 = feature off (no mention) sleep_budget: int = 0 @@ -145,6 +148,7 @@ def build_brief(inputs: BriefInputs, created: str) -> SessionBrief: ruler=_cap(inputs.ruler, MAX_RULER_CHARS), lessons=_cap(inputs.lessons, MAX_LESSONS_CHARS), recent_reports=reports, + report_archive=inputs.report_archive, budget=inputs.budget, created=created, launch_budget=inputs.launch_budget, @@ -222,6 +226,23 @@ def render(brief: SessionBrief) -> str: ] if brief.recent_reports: parts += ["", "# Recent run reports (newest first, including failures)", _DATA_NOTE] + parts += [ + "These are what past attempts on this benchmark tried and found — " + "negatives included. Read them critically: a negative settles " + "only what was actually run. One point in a parameter space, an " + "eval that hit its walltime, or an infrastructure failure does " + "not close an idea — vary what went untested, or rerun what " + "failed for reasons that were not the idea's. What it does " + "settle, do not repeat unchanged; build on it, or contradict it " + "with a reason." + + ( + " The full archive: `python .autoresearch/syscall reports` " + "lists every report one line each; add names to read full " + "reports, several in one call." + if brief.report_archive + else "" + ) + ] for i, report in enumerate(brief.recent_reports, 1): fence = _fence(report) parts += [f"\n## Report {i}", fence, report, fence] diff --git a/src/autoresearch/orchestrator.py b/src/autoresearch/orchestrator.py index dff39fd0..1d317afa 100644 --- a/src/autoresearch/orchestrator.py +++ b/src/autoresearch/orchestrator.py @@ -1088,6 +1088,7 @@ def attempt_once( changed_paths: Callable[[], Sequence[str]], lessons: str = "", recent_reports: tuple[str, ...] = (), + report_archive: bool = False, created: str = "", task_hypothesis: str = "", spec: RoleSpec | None = None, @@ -1162,7 +1163,12 @@ def attempt_once( # same optional-attr idiom as the panel policy raise ValueError("resume_session_id given but the harness does not support resume") if resume_session_id and ( - task_hypothesis or lessons or recent_reports or created or brief_baseline is not None + task_hypothesis + or lessons + or recent_reports + or report_archive + or created + or brief_baseline is not None ): # a resume skips build_brief entirely (the session already carries this # context from its first pass), so these brief-only inputs would be @@ -1176,8 +1182,8 @@ def attempt_once( # is NOT brief-only — scope/gate use it either way. raise ValueError( "resume_session_id resumes an existing session (no fresh brief); the brief-only " - "inputs (task_hypothesis/lessons/recent_reports/created/brief_baseline) have no " - "effect on a resume — omit them" + "inputs (task_hypothesis/lessons/recent_reports/report_archive/created/" + "brief_baseline) have no effect on a resume — omit them" ) # deferred like measure_and_decide's import (measure -> dispatch -> @@ -1219,6 +1225,7 @@ def attempt_once( ruler=ruler, lessons=lessons, recent_reports=recent_reports, + report_archive=report_archive, budget=config.budget, # the launch/sleep tool is advertised ONLY when it is wired # (never a tool the author cannot actually call) diff --git a/src/autoresearch/syscall_cli.py b/src/autoresearch/syscall_cli.py index 3c5d97e6..3cb4eb29 100644 --- a/src/autoresearch/syscall_cli.py +++ b/src/autoresearch/syscall_cli.py @@ -370,11 +370,48 @@ def build_parser() -> argparse.ArgumentParser: co = sub.add_parser("conclude", help="commit the verdict; then end your turn") co.add_argument("--notes", default="", help="summary the reader sees") # shared verbs + rp = sub.add_parser( + "reports", + help="past attempts' research reports: no names = a summary list; " + "names = the full reports (several in one call)", + ) + rp.add_argument("names", nargs="*", help="report file names from the summary list") sub.add_parser("status", help="show staged syscalls and remaining budget") sub.add_parser("cancel", help="discard the staged request") return p +def cmd_reports(root: Path, args) -> str: + """The research-report archive the kernel fetched for this run. With no + names: one summary line per report, newest first. With names: those + reports in full, in the order asked.""" + archive = root / "reports" + if not archive.is_dir(): + return "no report archive in this run (a first attempt on the target, or fetch failed)" + if args.names: + parts = [] + for name in args.names: + f = archive / name + if Path(name).name != name or not f.is_file(): + raise ToolError(f"no such report: {name} (run `reports` for the list)") + parts.append(f"=== {name}\n{f.read_text()}") + return "\n\n".join(parts) + lines = [] + for f in sorted(archive.glob("*.md"), reverse=True): + head = "" + for raw in f.read_text().splitlines(): + text = raw.strip() + if text.startswith("Outcome:"): + head = text + break + if not head and text and not text.startswith("#"): + head = text + lines.append(f"{f.name} {head[:120]}") + if not lines: + return "the report archive is empty" + return "\n".join(lines) + "\n(pass names to read full reports, several at once)" + + _HANDLERS = { "launch": cmd_launch, "note": cmd_note, @@ -382,6 +419,7 @@ def build_parser() -> argparse.ArgumentParser: "sleep": cmd_sleep, "finding": cmd_finding, "conclude": cmd_conclude, + "reports": cmd_reports, "status": cmd_status, "cancel": cmd_cancel, } diff --git a/tests/test_attempt.py b/tests/test_attempt.py index abb6fa50..b8358821 100644 --- a/tests/test_attempt.py +++ b/tests/test_attempt.py @@ -221,6 +221,74 @@ class D: assert _reconcile_launch_hours(old_sleep, d, 0, sweep) == 0.0 # type: ignore[arg-type] +def test_research_reports_fetch_and_archive(tmp_path) -> None: + """A fresh attempt pulls the target's research-log reports: newest first + for the brief, full texts into the channel archive; a target without the + branch is an empty memory, never an error. Only plain file names reach + the archive (branch content is remote-controlled).""" + from autoresearch.attempt import _fetch_research_reports, _install_report_archive + from autoresearch.github import Workspace + + origin = tmp_path / "origin.git" + _git(tmp_path, "init", "-q", "--bare", str(origin)) + seed = tmp_path / "seed" + seed.mkdir() + _git(seed, "init", "-q", "-b", "main") + (seed / "README.md").write_text("x") + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "add", "-A") + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "base") + _git(seed, "push", "-q", str(origin), "main") + ws_dir = tmp_path / "ws" + _git(tmp_path, "clone", "-q", str(origin), str(ws_dir)) + ws = Workspace(root=ws_dir) + + assert _fetch_research_reports(ws, 5) == [] # no research-log branch yet + + (seed / "reports").mkdir() + (seed / "reports" / "2026-08-28-run-a.md").write_text("Outcome: **no-improvement**\n") + (seed / "reports" / "2026-08-29-run-b.md").write_text("Outcome: **negative-result**\n") + # a nested path would flatten to a basename that overwrites another + # report (review #191 r2): only direct children are read + (seed / "reports" / "nested").mkdir() + (seed / "reports" / "nested" / "2026-08-29-run-b.md").write_text("shadow\n") + _git(seed, "checkout", "-qb", "research-log") + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "add", "-A") + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "reports") + _git(seed, "push", "-q", str(origin), "research-log") + + reports = _fetch_research_reports(ws, 5) + assert [n for n, _ in reports] == ["2026-08-29-run-b.md", "2026-08-28-run-a.md"] + assert "negative-result" in reports[0][1] # never the nested shadow + assert [n for n, _ in _fetch_research_reports(ws, 1)] == ["2026-08-29-run-b.md"] + + # a huge report (branch content is remote-controlled) is skipped by its + # blob size BEFORE `git show` would load it; the next report fills the slot + from autoresearch.attempt import MAX_ARCHIVED_REPORT_CHARS + + (seed / "reports" / "2026-08-30-run-c.md").write_text("x" * (MAX_ARCHIVED_REPORT_CHARS + 999)) + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "add", "-A") + _git(seed, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "big") + _git(seed, "push", "-q", str(origin), "research-log") + assert [n for n, _ in _fetch_research_reports(ws, 1)] == ["2026-08-29-run-b.md"] + + # production order: the tool installer recreates the channel dir it owns, + # so the archive must be written AFTER it (review #191: writing before + # deleted every archive) + from autoresearch.syscall import ensure_excluded, install_tool + + ensure_excluded(ws_dir) + install_tool(ws_dir) + _install_report_archive(ws_dir, [*reports, ("../escape.md", "nope")]) + assert (ws_dir / ".autoresearch" / "syscall").exists() # both survive + archive = ws_dir / ".autoresearch" / "reports" + assert sorted(f.name for f in archive.iterdir()) == [ + "2026-08-28-run-a.md", + "2026-08-29-run-b.md", + ] + assert not (ws_dir / ".autoresearch" / "escape.md").exists() + assert not (ws_dir / "escape.md").exists() + + def test_author_sleep_park_carries_the_gate_verdict(tmp_path) -> None: """A gate negative the author answered by launching more work rides the park, so the wake that ends on the same tree reuses it (review #182).""" diff --git a/tests/test_brief.py b/tests/test_brief.py index d507a286..9e83d54a 100644 --- a/tests/test_brief.py +++ b/tests/test_brief.py @@ -102,6 +102,18 @@ def test_render_tells_launches_where_artifacts_live() -> None: assert "--artifact" not in off +def test_render_points_reports_at_the_archive_views() -> None: + inputs = make_inputs(recent_reports=("Outcome: negative",), report_archive=True) + text = render(build_brief(inputs, created="t")) + assert "syscall reports" in text and "several" in text + # without the tool installed the reports still inline, but no command is + # advertised that does not exist (review #191) + no_tool = render(build_brief(make_inputs(recent_reports=("Outcome: negative",)), created="t")) + assert "Outcome: negative" in no_tool and "syscall reports" not in no_tool + off = render(build_brief(make_inputs(recent_reports=()), created="t")) + assert "syscall reports" not in off + + def test_render_omits_empty_memory_sections() -> None: text = render(build_brief(make_inputs(lessons="", recent_reports=()), created="t")) assert "# Lessons" not in text diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 65941f9c..0ff00704 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -205,9 +205,10 @@ def test_resume_entry_rejects_brief_only_inputs(tmp_path: Path) -> None: {"task_hypothesis": "try 3-opt"}, {"lessons": "prior climbs overfit the pool"}, {"recent_reports": ("last week's report",)}, + {"report_archive": True}, {"created": "2026-08-06T00:00:00Z"}, {"brief_baseline": 13.876}, - ): # every one of the five guarded brief-only inputs, so dropping any regresses + ): # every one of the six guarded brief-only inputs, so dropping any regresses with pytest.raises(ValueError, match="no effect on a resume"): run_climb( tmp_path, diff --git a/tests/test_syscall_cli.py b/tests/test_syscall_cli.py index 32ed7b25..04f559f2 100644 --- a/tests/test_syscall_cli.py +++ b/tests/test_syscall_cli.py @@ -362,3 +362,35 @@ def test_tool_command_is_absolute(tmp_path: Path) -> None: cmd = tool_command(tmp_path / "ws") assert cmd.startswith("python /") # absolute, resolves from any cwd assert cmd.endswith("/ws/.autoresearch/syscall") + + +def test_reports_summary_and_full_views(tmp_path: Path, capsys) -> None: + root = tmp_path / ".autoresearch" + archive = root / "reports" + archive.mkdir(parents=True) + (archive / "2026-08-28-speedrun-a.md").write_text( + "# Run report — org/repo / speedrun\nOutcome: **no-improvement**\nBaseline: 9472\n" + ) + (archive / "2026-08-29-speedrun-b.md").write_text( + "# Run report — org/repo / speedrun\nOutcome: **negative-result**\nBaseline: 9472\n" + ) + assert run(root, "reports", capsys=capsys) == 0 + lines = capsys.readouterr().out.splitlines() + assert lines[0].startswith("2026-08-29-speedrun-b.md") and "negative-result" in lines[0] + assert lines[1].startswith("2026-08-28-speedrun-a.md") + # several full reports in one call, in the order asked + both = ("2026-08-28-speedrun-a.md", "2026-08-29-speedrun-b.md") + assert run(root, "reports", *both, capsys=capsys) == 0 + out = capsys.readouterr().out + assert out.index("=== 2026-08-28-speedrun-a.md") < out.index("=== 2026-08-29-speedrun-b.md") + assert "Outcome: **no-improvement**" in out + # a wrong name fails loudly with the remedy; a path is not a name + assert run(root, "reports", "nope.md", capsys=capsys) != 0 + capsys.readouterr() + assert run(root, "reports", "../budget.json", capsys=capsys) != 0 + capsys.readouterr() + # no archive yet: a plain explanation, not an error + bare = tmp_path / "bare" / ".autoresearch" + bare.mkdir(parents=True) + assert run(bare, "reports", capsys=capsys) == 0 + assert "no report archive" in capsys.readouterr().out