renforge 0.1.0__py3-none-any.whl
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.
- renforge/__init__.py +4 -0
- renforge/__main__.py +5 -0
- renforge/activity_log.py +93 -0
- renforge/assets.py +149 -0
- renforge/autopilot.py +171 -0
- renforge/bridge/__init__.py +10 -0
- renforge/bridge/bridge.rpy +406 -0
- renforge/bridge/client.py +139 -0
- renforge/bridge/launcher.py +132 -0
- renforge/build.py +79 -0
- renforge/cli.py +103 -0
- renforge/docs.py +122 -0
- renforge/dump.py +80 -0
- renforge/lint.py +194 -0
- renforge/project.py +54 -0
- renforge/scanner.py +247 -0
- renforge/sdk.py +263 -0
- renforge/server.py +439 -0
- renforge/tools/__init__.py +3 -0
- renforge/tools/live.py +274 -0
- renforge/tools/project_ops.py +127 -0
- renforge/tools/static.py +65 -0
- renforge/translation.py +206 -0
- renforge/ui/__init__.py +14 -0
- renforge/ui/activity.py +98 -0
- renforge/ui/graph.py +254 -0
- renforge/ui/poller.py +67 -0
- renforge/ui/server.py +330 -0
- renforge/ui/static/assets/index-g2AQglUZ.js +92 -0
- renforge/ui/static/assets/index-wSw967Fa.css +1 -0
- renforge/ui/static/index.html +13 -0
- renforge/ui/ws.py +55 -0
- renforge/util/__init__.py +10 -0
- renforge/util/files.py +33 -0
- renforge/util/subprocess.py +95 -0
- renforge-0.1.0.dist-info/METADATA +171 -0
- renforge-0.1.0.dist-info/RECORD +40 -0
- renforge-0.1.0.dist-info/WHEEL +4 -0
- renforge-0.1.0.dist-info/entry_points.txt +2 -0
- renforge-0.1.0.dist-info/licenses/LICENSE +21 -0
renforge/__init__.py
ADDED
renforge/__main__.py
ADDED
renforge/activity_log.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Activity feed helpers for MCP tool calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _coerce_project_root(project_root: str | Path) -> Path:
|
|
13
|
+
return Path(project_root).expanduser().resolve()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _coerce_files_touched(value: Any) -> list[str]:
|
|
17
|
+
if isinstance(value, str):
|
|
18
|
+
return [value]
|
|
19
|
+
if isinstance(value, (list, tuple, set)):
|
|
20
|
+
return [str(item) for item in value if isinstance(item, (str, Path))]
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _coerce_result_payload(result: Any) -> Any:
|
|
25
|
+
if isinstance(result, dict) and "ok" in result:
|
|
26
|
+
return result
|
|
27
|
+
if isinstance(result, (str, int, float, bool, list, type(None))):
|
|
28
|
+
return result
|
|
29
|
+
return str(result)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _coerce_payload(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, (str, int, float, bool, type(None))):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
return [_coerce_payload(item) for item in value]
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
return {str(k): _coerce_payload(v) for k, v in value.items()}
|
|
39
|
+
if isinstance(value, set):
|
|
40
|
+
return sorted(str(item) for item in value)
|
|
41
|
+
if isinstance(value, tuple):
|
|
42
|
+
return [_coerce_payload(item) for item in value]
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def summarize_result(result: Any) -> dict[str, Any]:
|
|
47
|
+
if isinstance(result, dict):
|
|
48
|
+
ok = result.get("ok", not isinstance(result.get("error"), str))
|
|
49
|
+
files_touched: list[str] = []
|
|
50
|
+
for key in ("files_touched", "files", "changed_files", "changed", "file_touches"):
|
|
51
|
+
candidate = result.get(key)
|
|
52
|
+
if candidate:
|
|
53
|
+
files_touched = _coerce_files_touched(candidate)
|
|
54
|
+
break
|
|
55
|
+
return {"ok": bool(ok), "files_touched": files_touched, "result": result}
|
|
56
|
+
|
|
57
|
+
if isinstance(result, (str, int, float, bool, list, type(None))):
|
|
58
|
+
return {"ok": True, "files_touched": [], "result": result}
|
|
59
|
+
|
|
60
|
+
return {"ok": True, "files_touched": [], "result": str(result)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def log_tool_call(
|
|
64
|
+
project_root: str | Path,
|
|
65
|
+
name: str,
|
|
66
|
+
params: dict[str, Any],
|
|
67
|
+
duration_ms: float,
|
|
68
|
+
result: Any,
|
|
69
|
+
files_touched: list[str] | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
summary = summarize_result(result)
|
|
72
|
+
entry = {
|
|
73
|
+
"ts": int(time.time() * 1000),
|
|
74
|
+
"name": name,
|
|
75
|
+
"params": _coerce_payload(params),
|
|
76
|
+
"duration_ms": duration_ms,
|
|
77
|
+
"ok": summary["ok"],
|
|
78
|
+
"result": _coerce_result_payload(summary["result"]),
|
|
79
|
+
"files_touched": files_touched or summary["files_touched"],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
root = _coerce_project_root(project_root)
|
|
83
|
+
if not root.exists() or not root.is_dir():
|
|
84
|
+
return
|
|
85
|
+
path = root / ".renforge" / "activity.jsonl"
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
payload = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
|
|
89
|
+
with path.open("a", encoding="utf-8") as file_obj:
|
|
90
|
+
file_obj.write(payload)
|
|
91
|
+
file_obj.write("\n")
|
|
92
|
+
file_obj.flush()
|
|
93
|
+
os.fsync(file_obj.fileno())
|
renforge/assets.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Asset analysis: find orphaned and missing images/audio in a Ren'Py project.
|
|
2
|
+
|
|
3
|
+
Heuristic and deliberately conservative — Ren'Py's image resolution is dynamic
|
|
4
|
+
(``show eileen happy`` maps to a defined image or a file like
|
|
5
|
+
``images/eileen happy.png``), so this reports *likely* orphans/missing rather
|
|
6
|
+
than a proof. It reads the ``game/`` tree and the ``.rpy`` sources; the engine
|
|
7
|
+
stays the source of truth via ``lint``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".tga", ".bmp"}
|
|
18
|
+
AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".flac", ".m4a", ".aac", ".mp2", ".wma"}
|
|
19
|
+
VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".avi", ".mkv", ".mov", ".mpg", ".mpeg", ".flv"}
|
|
20
|
+
ASSET_EXTS = IMAGE_EXTS | AUDIO_EXTS | VIDEO_EXTS
|
|
21
|
+
|
|
22
|
+
_QUOTED_RE = re.compile(r"""["']([^"'\n]+?)["']""")
|
|
23
|
+
_IMAGE_DEF_RE = re.compile(r"^\s*image\s+([^\n=:]+?)\s*(?:=|:)")
|
|
24
|
+
_SCENE_SHOW_RE = re.compile(r"^\s*(?:scene|show)\s+(.+?)\s*(?:#.*)?$")
|
|
25
|
+
# Tokens that end the image-name part of a scene/show statement.
|
|
26
|
+
_SHOW_STOP = {"at", "with", "as", "behind", "onlayer", "zorder", "expression"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _game_dir(project_path: str | Path) -> Path:
|
|
30
|
+
return Path(project_path).expanduser().resolve() / "game"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iter_rpy(game: Path):
|
|
34
|
+
for root, _dirs, files in os.walk(game):
|
|
35
|
+
for name in files:
|
|
36
|
+
if name.endswith(".rpy"):
|
|
37
|
+
yield Path(root) / name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _image_name_from_show(rest: str) -> str:
|
|
41
|
+
tokens = rest.split()
|
|
42
|
+
keep: list[str] = []
|
|
43
|
+
for tok in tokens:
|
|
44
|
+
if tok in _SHOW_STOP:
|
|
45
|
+
break
|
|
46
|
+
keep.append(tok)
|
|
47
|
+
return " ".join(keep)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analyze_assets(project_path: str | Path) -> dict[str, Any]:
|
|
51
|
+
game = _game_dir(project_path)
|
|
52
|
+
result: dict[str, Any] = {
|
|
53
|
+
"asset_files": [],
|
|
54
|
+
"orphans": [],
|
|
55
|
+
"missing_files": [],
|
|
56
|
+
"undefined_images": [],
|
|
57
|
+
}
|
|
58
|
+
if not game.is_dir():
|
|
59
|
+
result["error"] = f"no game/ directory under {project_path}"
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
# 1. Asset files on disk (relative to game/, posix-style).
|
|
63
|
+
disk_files: list[str] = []
|
|
64
|
+
for root, _dirs, files in os.walk(game):
|
|
65
|
+
for name in files:
|
|
66
|
+
if Path(name).suffix.lower() in ASSET_EXTS:
|
|
67
|
+
rel = (Path(root) / name).relative_to(game).as_posix()
|
|
68
|
+
disk_files.append(rel)
|
|
69
|
+
disk_files.sort()
|
|
70
|
+
result["asset_files"] = disk_files
|
|
71
|
+
|
|
72
|
+
# 2. References from the scripts.
|
|
73
|
+
quoted: set[str] = set()
|
|
74
|
+
defined_images: set[str] = set()
|
|
75
|
+
shown_images: set[str] = set()
|
|
76
|
+
for rpy in _iter_rpy(game):
|
|
77
|
+
try:
|
|
78
|
+
text = rpy.read_text(encoding="utf-8", errors="replace")
|
|
79
|
+
except OSError:
|
|
80
|
+
continue
|
|
81
|
+
for line in text.splitlines():
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
if not stripped or stripped.startswith("#"):
|
|
84
|
+
continue
|
|
85
|
+
for m in _QUOTED_RE.finditer(line):
|
|
86
|
+
quoted.add(m.group(1))
|
|
87
|
+
dm = _IMAGE_DEF_RE.match(line)
|
|
88
|
+
if dm:
|
|
89
|
+
defined_images.add(dm.group(1).strip())
|
|
90
|
+
sm = _SCENE_SHOW_RE.match(line)
|
|
91
|
+
if sm:
|
|
92
|
+
name = _image_name_from_show(sm.group(1))
|
|
93
|
+
if name:
|
|
94
|
+
shown_images.add(name)
|
|
95
|
+
|
|
96
|
+
quoted_basenames = {Path(q).name for q in quoted}
|
|
97
|
+
|
|
98
|
+
def _referenced(rel: str) -> bool:
|
|
99
|
+
base = Path(rel).name
|
|
100
|
+
stem = Path(rel).stem # image name candidate, e.g. "eileen happy"
|
|
101
|
+
if rel in quoted or base in quoted_basenames:
|
|
102
|
+
return True
|
|
103
|
+
if any(q.endswith(rel) or q.endswith(base) for q in quoted):
|
|
104
|
+
return True
|
|
105
|
+
# Image files referenced via `scene/show <name>` or `image <name>`.
|
|
106
|
+
if Path(rel).suffix.lower() in IMAGE_EXTS:
|
|
107
|
+
if stem in shown_images or stem in defined_images:
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
result["orphans"] = [rel for rel in disk_files if not _referenced(rel)]
|
|
112
|
+
|
|
113
|
+
# 3. Missing: quoted references that look like *developer* asset paths but
|
|
114
|
+
# aren't on disk. We skip substitution patterns ("[prefix_]...") and the
|
|
115
|
+
# gui/ tree, whose images the GUI framework generates or renders by
|
|
116
|
+
# default — flagging those would be noise, not actionable findings.
|
|
117
|
+
disk_set = set(disk_files)
|
|
118
|
+
disk_basenames = {Path(f).name for f in disk_files}
|
|
119
|
+
for q in sorted(quoted):
|
|
120
|
+
if "[" in q or "]" in q:
|
|
121
|
+
continue
|
|
122
|
+
if q.startswith("gui/"):
|
|
123
|
+
continue
|
|
124
|
+
if Path(q).suffix.lower() in ASSET_EXTS:
|
|
125
|
+
if q not in disk_set and Path(q).name not in disk_basenames:
|
|
126
|
+
result["missing_files"].append(q)
|
|
127
|
+
|
|
128
|
+
# 4. Images shown but neither defined nor backed by a file (Ren'Py would use
|
|
129
|
+
# a placeholder). Reported separately as a soft signal.
|
|
130
|
+
disk_stems = {Path(f).stem for f in disk_files if Path(f).suffix.lower() in IMAGE_EXTS}
|
|
131
|
+
for name in sorted(shown_images):
|
|
132
|
+
if name in defined_images:
|
|
133
|
+
continue
|
|
134
|
+
# A single-tag show may resolve to "<first-tag>.png"; check tag stems too.
|
|
135
|
+
first = name.split()[0] if name.split() else name
|
|
136
|
+
if name in disk_stems or first in disk_stems or first in defined_images:
|
|
137
|
+
continue
|
|
138
|
+
result["undefined_images"].append(name)
|
|
139
|
+
|
|
140
|
+
result["summary"] = {
|
|
141
|
+
"asset_count": len(disk_files),
|
|
142
|
+
"orphan_count": len(result["orphans"]),
|
|
143
|
+
"missing_count": len(result["missing_files"]),
|
|
144
|
+
"undefined_image_count": len(result["undefined_images"]),
|
|
145
|
+
}
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["analyze_assets"]
|
renforge/autopilot.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Autopilot: explore a Ren'Py visual novel and report label coverage + crashes.
|
|
2
|
+
|
|
3
|
+
Strategy — systematic branch exploration by replay. Each run launches the game
|
|
4
|
+
fresh, replays a fixed prefix of menu choices, then at the first *new* menu it
|
|
5
|
+
takes choice 0 and queues the remaining choices as future runs. Repeating until
|
|
6
|
+
the frontier is empty covers every branch combination, using only primitives
|
|
7
|
+
that work reliably (launch / advance / list_choices / select_choice /
|
|
8
|
+
poll_events) — no in-game save/load, which cannot be driven from the bridge's
|
|
9
|
+
main-thread callback.
|
|
10
|
+
|
|
11
|
+
Menus are detected by ``list_choices`` returning options. (A game with an
|
|
12
|
+
always-on quick menu could surface non-choice buttons here; refining detection
|
|
13
|
+
to the active ``choice`` screen is a future improvement.)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
from collections import deque
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from .bridge.launcher import launch_with_bridge
|
|
25
|
+
from .project import RenpyProject
|
|
26
|
+
from .scanner import scan_project
|
|
27
|
+
from .sdk import RenpySdk
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _story_labels(project_path: str | Path) -> set[str]:
|
|
31
|
+
# Labels authored in the project, excluding Ren'Py-internal (underscore) ones.
|
|
32
|
+
index = scan_project(str(project_path))
|
|
33
|
+
return {label["name"] for label in index.get("labels", []) if not label["name"].startswith("_")}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _menu_choices(client) -> list[dict]:
|
|
37
|
+
"""Choices that belong to the active ``choice`` screen (ignores quick menu)."""
|
|
38
|
+
return [c for c in client.list_choices() if c.get("screen") == "choice"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def autopilot(
|
|
42
|
+
sdk: RenpySdk,
|
|
43
|
+
project: RenpyProject,
|
|
44
|
+
*,
|
|
45
|
+
max_runs: int = 16,
|
|
46
|
+
max_steps: int = 60,
|
|
47
|
+
settle: float = 0.4,
|
|
48
|
+
startup_timeout: float = 90.0,
|
|
49
|
+
progress_callback: Callable[[dict], None] | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Explore the game and return a coverage/crash report.
|
|
52
|
+
|
|
53
|
+
After each run a partial report is written to
|
|
54
|
+
``<project>/.renforge/autopilot.json`` and passed to ``progress_callback``
|
|
55
|
+
(if given), so long explorations can be followed incrementally.
|
|
56
|
+
"""
|
|
57
|
+
total_labels = _story_labels(project.root)
|
|
58
|
+
|
|
59
|
+
reached: set[str] = set()
|
|
60
|
+
dialogue: set[str] = set()
|
|
61
|
+
crashes: list[dict] = []
|
|
62
|
+
choices_explored = 0
|
|
63
|
+
|
|
64
|
+
frontier: deque[list[int]] = deque([[]])
|
|
65
|
+
seen_prefixes: set[tuple[int, ...]] = set()
|
|
66
|
+
runs = 0
|
|
67
|
+
|
|
68
|
+
progress_path = project.cache_dir / "autopilot.json"
|
|
69
|
+
|
|
70
|
+
def _report(done: bool) -> dict[str, Any]:
|
|
71
|
+
covered = sorted(total_labels & reached)
|
|
72
|
+
return {
|
|
73
|
+
"ok": True,
|
|
74
|
+
"done": done,
|
|
75
|
+
"runs": runs,
|
|
76
|
+
"runs_pending": len(frontier),
|
|
77
|
+
"labels_total": len(total_labels),
|
|
78
|
+
"labels_reached": sorted(reached),
|
|
79
|
+
"labels_covered": covered,
|
|
80
|
+
"labels_unreached": sorted(total_labels - reached),
|
|
81
|
+
"coverage": round(len(covered) / len(total_labels), 3) if total_labels else 1.0,
|
|
82
|
+
"dialogue_lines": len(dialogue),
|
|
83
|
+
"choices_explored": choices_explored,
|
|
84
|
+
"crashes": crashes,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _emit(done: bool) -> None:
|
|
88
|
+
report = _report(done)
|
|
89
|
+
try:
|
|
90
|
+
progress_path.write_text(json.dumps(report), encoding="utf-8")
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
if progress_callback is not None:
|
|
94
|
+
try:
|
|
95
|
+
progress_callback(report)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def _drain(client, cursor: int, run_labels: list[str]) -> int:
|
|
100
|
+
events = client.poll_events(since=cursor)
|
|
101
|
+
for event in events["events"]:
|
|
102
|
+
kind = event.get("type")
|
|
103
|
+
if kind == "label":
|
|
104
|
+
name = event.get("label")
|
|
105
|
+
if name and not name.startswith("_"):
|
|
106
|
+
reached.add(name)
|
|
107
|
+
run_labels.append(name)
|
|
108
|
+
elif kind == "say":
|
|
109
|
+
dialogue.add(event.get("what"))
|
|
110
|
+
elif kind == "exception":
|
|
111
|
+
crashes.append({"short": event.get("short"), "full": event.get("full")})
|
|
112
|
+
return events["cursor"]
|
|
113
|
+
|
|
114
|
+
while frontier and runs < max_runs:
|
|
115
|
+
prefix = frontier.popleft()
|
|
116
|
+
if tuple(prefix) in seen_prefixes:
|
|
117
|
+
continue
|
|
118
|
+
seen_prefixes.add(tuple(prefix))
|
|
119
|
+
runs += 1
|
|
120
|
+
|
|
121
|
+
session = launch_with_bridge(sdk, project, startup_timeout=startup_timeout)
|
|
122
|
+
try:
|
|
123
|
+
cursor = 0
|
|
124
|
+
seq: list[int] = list(prefix)
|
|
125
|
+
seq_pos = 0
|
|
126
|
+
run_labels: list[str] = []
|
|
127
|
+
|
|
128
|
+
for _step in range(max_steps):
|
|
129
|
+
cursor = _drain(session.client, cursor, run_labels)
|
|
130
|
+
|
|
131
|
+
# Loop guard: a repeated story label means the game cycled back
|
|
132
|
+
# (e.g. returned to the main menu); stop this run so we don't
|
|
133
|
+
# keep re-answering the same menu forever.
|
|
134
|
+
if len(run_labels) != len(set(run_labels)):
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
choices = _menu_choices(session.client)
|
|
138
|
+
if choices:
|
|
139
|
+
# Let the menu's show transition finish so the choice buttons
|
|
140
|
+
# are at stable positions before we click one.
|
|
141
|
+
time.sleep(settle)
|
|
142
|
+
choices = _menu_choices(session.client) or choices
|
|
143
|
+
if seq_pos < len(seq):
|
|
144
|
+
idx = seq[seq_pos]
|
|
145
|
+
else:
|
|
146
|
+
# New branch point: take choice 0, queue the alternatives.
|
|
147
|
+
for alt in range(1, len(choices)):
|
|
148
|
+
frontier.append(seq + [alt])
|
|
149
|
+
idx = 0
|
|
150
|
+
seq.append(0)
|
|
151
|
+
seq_pos += 1
|
|
152
|
+
idx = min(idx, len(choices) - 1)
|
|
153
|
+
# Select by visible text (the reliable focus-resolution path).
|
|
154
|
+
session.client.select_choice(text=choices[idx]["text"])
|
|
155
|
+
choices_explored += 1
|
|
156
|
+
time.sleep(settle)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
session.client.advance()
|
|
160
|
+
time.sleep(settle)
|
|
161
|
+
|
|
162
|
+
_drain(session.client, cursor, run_labels)
|
|
163
|
+
finally:
|
|
164
|
+
session.close()
|
|
165
|
+
|
|
166
|
+
_emit(done=not frontier or runs >= max_runs)
|
|
167
|
+
|
|
168
|
+
return _report(done=True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["autopilot"]
|