Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,25 @@ open-ended sweep space.
- [ ] Periodic distillation pass: raw reports → bounded lessons/<target>.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
Expand Down
58 changes: 58 additions & 0 deletions src/autoresearch/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<YYYY-MM-DD>-<run_id>.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
Expand Down Expand Up @@ -1876,6 +1928,7 @@ def live_attempt(
channel.is_symlink(),
)
author_syscalls = False
reports = _fetch_research_reports(ws, MAX_ARCHIVED_REPORTS)
Comment thread
renmengye marked this conversation as resolved.
Resolved
if author_syscalls:
assert _bench is not None
syscall_excluded(workspace)
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/autoresearch/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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` "
Comment thread
renmengye marked this conversation as resolved.
Resolved
"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]
Expand Down
13 changes: 10 additions & 3 deletions src/autoresearch/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ def attempt_once(
changed_paths: Callable[[], Sequence[str]],
lessons: str = "",
recent_reports: tuple[str, ...] = (),
report_archive: bool = False,
Comment thread
renmengye marked this conversation as resolved.
Resolved
created: str = "",
task_hypothesis: str = "",
spec: RoleSpec | None = None,
Expand Down Expand Up @@ -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
Expand All @@ -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 ->
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions src/autoresearch/syscall_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,18 +370,56 @@ 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,
"submit": cmd_submit,
"sleep": cmd_sleep,
"finding": cmd_finding,
"conclude": cmd_conclude,
"reports": cmd_reports,
"status": cmd_status,
"cancel": cmd_cancel,
}
Expand Down
68 changes: 68 additions & 0 deletions tests/test_attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")])
Comment thread
renmengye marked this conversation as resolved.
Resolved
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)."""
Expand Down
12 changes: 12 additions & 0 deletions tests/test_brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading