Coverage for little_loops / cli / loop / next_loop.py: 82%

181 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""ll-loop next-loop: Suggest the next loop to run from execution history.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import json 

7from dataclasses import dataclass 

8from datetime import UTC, datetime 

9from pathlib import Path 

10from typing import Any 

11 

12from little_loops.logger import Logger 

13 

14 

15@dataclass 

16class LoopCandidate: 

17 """A scored loop candidate for next-loop suggestions.""" 

18 

19 loop: str 

20 score: float 

21 input: str | None 

22 context: dict[str, str] 

23 rationale: str 

24 command: str 

25 run_count: int = 0 

26 last_run: str | None = None 

27 success_rate: float = 1.0 

28 

29 def to_dict(self) -> dict[str, Any]: 

30 return { 

31 "loop": self.loop, 

32 "input": self.input, 

33 "context": self.context, 

34 "score": round(self.score, 4), 

35 "rationale": self.rationale, 

36 "command": self.command, 

37 } 

38 

39 

40# --------------------------------------------------------------------------- 

41# History scanning 

42# --------------------------------------------------------------------------- 

43 

44 

45def _scan_history(loops_dir: Path) -> dict[str, list[dict[str, Any]]]: 

46 """Scan .loops/.history/ and return per-loop run metadata. 

47 

48 Returns dict mapping loop_name → list of {status, started_at, iterations}. 

49 """ 

50 from little_loops.fsm.persistence import HISTORY_DIR, _parse_run_folder 

51 

52 history_base = loops_dir / HISTORY_DIR 

53 if not history_base.exists(): 

54 return {} 

55 

56 per_loop: dict[str, list[dict[str, Any]]] = {} 

57 for run_dir in sorted(history_base.iterdir()): 

58 if not run_dir.is_dir(): 

59 continue 

60 parsed = _parse_run_folder(run_dir.name) 

61 if not parsed: 

62 continue 

63 run_id, loop_name = parsed 

64 state_file = run_dir / "state.json" 

65 entry: dict[str, Any] = {"run_id": run_id, "status": None, "started_at": None} 

66 if state_file.exists(): 

67 try: 

68 data = json.loads(state_file.read_text()) 

69 entry["status"] = data.get("status") 

70 entry["started_at"] = data.get("started_at") 

71 except (ValueError, OSError): 

72 pass 

73 per_loop.setdefault(loop_name, []).append(entry) 

74 

75 return per_loop 

76 

77 

78# --------------------------------------------------------------------------- 

79# Scoring 

80# --------------------------------------------------------------------------- 

81 

82_SUCCESS_STATUSES = {"completed"} 

83_DECAY_HALF_LIFE_DAYS = 7.0 # recency decay: halves every 7 days 

84 

85 

86def _recency_score(started_at: str | None) -> float: 

87 """Exponential decay score in [0, 1] based on days since last run.""" 

88 if not started_at: 

89 return 0.0 

90 try: 

91 ts = datetime.fromisoformat(started_at.replace("Z", "+00:00")) 

92 now = datetime.now(UTC) 

93 days = (now - ts).total_seconds() / 86400.0 

94 import math 

95 

96 return math.exp(-days * math.log(2) / _DECAY_HALF_LIFE_DAYS) 

97 except (ValueError, TypeError): 

98 return 0.0 

99 

100 

101def _score_loop(runs: list[dict[str, Any]]) -> tuple[float, float, str | None]: 

102 """Return (score, success_rate, last_started_at).""" 

103 if not runs: 

104 return 0.0, 1.0, None 

105 

106 count = len(runs) 

107 successes = sum(1 for r in runs if r.get("status") in _SUCCESS_STATUSES) 

108 success_rate = successes / count if count else 1.0 

109 

110 # Most recent run for recency 

111 dated = [r for r in runs if r.get("started_at")] 

112 dated.sort(key=lambda r: r["started_at"], reverse=True) 

113 last_started_at = dated[0]["started_at"] if dated else None 

114 

115 recency = _recency_score(last_started_at) 

116 # Weighted: 50% frequency (log-scale), 30% recency, 20% success rate 

117 import math 

118 

119 freq_score = math.log1p(count) / math.log1p(50) # cap normalisation at 50 runs 

120 score = 0.50 * freq_score + 0.30 * recency + 0.20 * success_rate 

121 return score, success_rate, last_started_at 

122 

123 

124# --------------------------------------------------------------------------- 

125# Parameter resolver registry 

126# --------------------------------------------------------------------------- 

127 

128_ParamResolver = dict[str, str | None] # {input: ..., **context_keys} 

129 

130 

131def _resolve_autodev_params(_loops_dir: Path) -> _ParamResolver: # noqa: ARG001 

132 """Resolve input params for autodev: return space-joined active issue IDs.""" 

133 try: 

134 from pathlib import Path as _Path 

135 

136 from little_loops.cli.issues.search import _load_issues_with_status 

137 from little_loops.config import BRConfig 

138 

139 config = BRConfig(_Path.cwd()) 

140 raw = _load_issues_with_status( 

141 config, include_open=True, include_done=False, include_deferred=False 

142 ) 

143 ids = [issue.issue_id for issue, _ in raw if issue.issue_id] 

144 if ids: 

145 return {"input": " ".join(ids)} 

146 except Exception: 

147 pass 

148 return {} 

149 

150 

151# Registry: loop name → resolver callable 

152_PARAM_RESOLVERS: dict[str, Any] = { 

153 "autodev": _resolve_autodev_params, 

154} 

155 

156 

157def _resolve_params(loop_name: str, loops_dir: Path) -> _ParamResolver: 

158 """Return resolved params for a loop, falling back to empty dict.""" 

159 resolver = _PARAM_RESOLVERS.get(loop_name) 

160 if resolver is not None: 

161 try: 

162 return resolver(loops_dir) 

163 except Exception: 

164 pass 

165 return {} 

166 

167 

168# --------------------------------------------------------------------------- 

169# Command building 

170# --------------------------------------------------------------------------- 

171 

172 

173def _build_command(loop_name: str, params: _ParamResolver) -> str: 

174 """Build a shell-ready ll-loop run command string.""" 

175 parts = ["ll-loop", "run", loop_name] 

176 if params.get("input"): 

177 parts.append(json.dumps(params["input"])) 

178 for k, v in params.items(): 

179 if k != "input" and v is not None: 

180 parts.extend(["--context", f"{k}={v}"]) 

181 return " ".join(parts) 

182 

183 

184def _build_rationale( 

185 run_count: int, 

186 success_rate: float, 

187 last_started_at: str | None, 

188 param_note: str, 

189) -> str: 

190 parts = [f"{run_count} run{'s' if run_count != 1 else ''}"] 

191 if last_started_at: 

192 try: 

193 ts = datetime.fromisoformat(last_started_at.replace("Z", "+00:00")) 

194 ago = datetime.now(UTC) - ts 

195 days = int(ago.total_seconds() / 86400) 

196 if days == 0: 

197 parts.append("last run today") 

198 elif days == 1: 

199 parts.append("last run yesterday") 

200 else: 

201 parts.append(f"last run {days}d ago") 

202 except (ValueError, TypeError): 

203 pass 

204 parts.append(f"{int(success_rate * 100)}% success") 

205 if param_note: 

206 parts.append(param_note) 

207 return "; ".join(parts) 

208 

209 

210# --------------------------------------------------------------------------- 

211# cmd_next_loop 

212# --------------------------------------------------------------------------- 

213 

214 

215def cmd_next_loop( 

216 args: argparse.Namespace, 

217 loops_dir: Path, 

218 logger: Logger, 

219) -> int: 

220 """Suggest the next loop(s) to run based on execution history.""" 

221 from little_loops.cli.output import colorize, print_json 

222 

223 count = getattr(args, "count", 1) 

224 as_json = getattr(args, "json", False) 

225 execute = getattr(args, "execute", False) 

226 exclude = set(getattr(args, "exclude", None) or []) 

227 

228 history = _scan_history(loops_dir) 

229 if not history: 

230 if as_json: 

231 print_json([]) 

232 else: 

233 print("No loop history available. Run some loops first.") 

234 return 1 

235 

236 # Score each loop 

237 scored: list[tuple[float, str, float, str | None, int]] = [] 

238 for loop_name, runs in history.items(): 

239 if loop_name in exclude: 

240 continue 

241 score, success_rate, last_started_at = _score_loop(runs) 

242 scored.append((score, loop_name, success_rate, last_started_at, len(runs))) 

243 

244 if not scored: 

245 if as_json: 

246 print_json([]) 

247 else: 

248 print("No candidates after applying exclusions.") 

249 return 1 

250 

251 scored.sort(key=lambda t: t[0], reverse=True) 

252 top = scored[:count] 

253 

254 candidates: list[LoopCandidate] = [] 

255 for score, loop_name, success_rate, last_started_at, run_count in top: 

256 params = _resolve_params(loop_name, loops_dir) 

257 param_note = "" 

258 resolved_input = params.get("input") 

259 if resolved_input: 

260 item_count = len(resolved_input.split()) 

261 param_note = ( 

262 f"input resolved ({item_count} items)" if item_count > 1 else "input resolved" 

263 ) 

264 elif loop_name in _PARAM_RESOLVERS: 

265 param_note = "input resolver found no active items" 

266 

267 rationale = _build_rationale(run_count, success_rate, last_started_at, param_note) 

268 command = _build_command(loop_name, params) 

269 

270 candidate = LoopCandidate( 

271 loop=loop_name, 

272 score=score, 

273 input=params.get("input"), 

274 context={k: v for k, v in params.items() if k != "input" and v is not None}, 

275 rationale=rationale, 

276 command=command, 

277 run_count=run_count, 

278 last_run=last_started_at, 

279 success_rate=success_rate, 

280 ) 

281 candidates.append(candidate) 

282 

283 if as_json: 

284 print_json([c.to_dict() for c in candidates]) 

285 return 0 

286 

287 # Text output 

288 label = "suggestion" if len(candidates) == 1 else "suggestions" 

289 print(colorize(f"Next loop {label}:", "1")) 

290 print() 

291 for i, c in enumerate(candidates, 1): 

292 rank = colorize(f"#{i}", "36;1") 

293 name = colorize(c.loop, "1") 

294 score_str = colorize(f"score={c.score:.3f}", "2") 

295 print(f" {rank} {name} {score_str}") 

296 print(f" {colorize(c.rationale, '2')}") 

297 print(f" {colorize('$', '32')} {c.command}") 

298 print() 

299 

300 if execute: 

301 top_candidate = candidates[0] 

302 logger.info(f"Executing: {top_candidate.command}") 

303 # Build minimal Namespace to call cmd_run 

304 from little_loops.cli.loop.run import cmd_run 

305 

306 run_args = argparse.Namespace( 

307 input=top_candidate.input, 

308 max_iterations=None, 

309 delay=None, 

310 no_llm=False, 

311 llm_model=None, 

312 dry_run=False, 

313 background=False, 

314 foreground_internal=False, 

315 instance_id=None, 

316 quiet=False, 

317 verbose=False, 

318 show_diagrams=False, 

319 clear=False, 

320 queue=False, 

321 context=[f"{k}={v}" for k, v in top_candidate.context.items()], 

322 program_md=None, 

323 builtin=False, 

324 worktree=False, 

325 handoff_threshold=None, 

326 context_limit=None, 

327 ) 

328 return cmd_run(top_candidate.loop, run_args, loops_dir, logger) 

329 

330 return 0