loopy-loop 0.2.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.
Files changed (33) hide show
  1. loopy_loop/__init__.py +1 -0
  2. loopy_loop/cli.py +293 -0
  3. loopy_loop/config.py +491 -0
  4. loopy_loop/coordinator_app.py +706 -0
  5. loopy_loop/harness_runner.py +195 -0
  6. loopy_loop/models.py +163 -0
  7. loopy_loop/scheduler.py +232 -0
  8. loopy_loop/sessions.py +313 -0
  9. loopy_loop/state_store.py +110 -0
  10. loopy_loop/templates/inner_outer_eval/.gitignore +4 -0
  11. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/config.yaml +11 -0
  12. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt +97 -0
  13. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/config.yaml +12 -0
  14. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt +86 -0
  15. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/config.yaml +8 -0
  16. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt +223 -0
  17. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/config.yaml +8 -0
  18. loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt +333 -0
  19. loopy_loop/templates/inner_outer_eval/loopy_loop_config.yaml +45 -0
  20. loopy_loop/templates/inner_outer_eval/loopy_loop_goal.txt +5 -0
  21. loopy_loop/templates/pm_planner_dispatcher/.gitignore +1 -0
  22. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/config.yaml +8 -0
  23. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt +78 -0
  24. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/config.yaml +9 -0
  25. loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/prompt.txt +99 -0
  26. loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml +24 -0
  27. loopy_loop/templates/pm_planner_dispatcher/loopy_loop_goal.txt +1 -0
  28. loopy_loop/worker.py +265 -0
  29. loopy_loop-0.2.0.dist-info/METADATA +408 -0
  30. loopy_loop-0.2.0.dist-info/RECORD +33 -0
  31. loopy_loop-0.2.0.dist-info/WHEEL +4 -0
  32. loopy_loop-0.2.0.dist-info/entry_points.txt +3 -0
  33. loopy_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
loopy_loop/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
loopy_loop/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.resources import files
4
+ from importlib.resources.abc import Traversable
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import uvicorn
9
+
10
+ from loopy_loop.config import ConfigError
11
+ from loopy_loop.config import DEFAULT_GOAL_FILENAME
12
+ from loopy_loop.config import LOOPY_DIRNAME
13
+ from loopy_loop.config import ROOT_CONFIG_FILENAME
14
+ from loopy_loop.coordinator_app import create_coordinator_app
15
+ from loopy_loop.models import LoopState
16
+ from loopy_loop.state_store import StateStore
17
+ from loopy_loop.worker import run_worker_loop
18
+
19
+ GOAL_CHECK_WORKFLOW_ID = "goal_check"
20
+ DEFAULT_TEMPLATE_NAME = "default"
21
+ MAIN_WORKFLOW_SET_NAME = "main"
22
+ INNER_OUTER_EVAL_TEMPLATE_NAME = "inner_outer_eval"
23
+ PM_PLANNER_DISPATCHER_TEMPLATE_NAME = "pm_planner_dispatcher"
24
+ PACKAGED_TEMPLATE_FILES_BY_NAME = {
25
+ INNER_OUTER_EVAL_TEMPLATE_NAME: [
26
+ ".gitignore",
27
+ ROOT_CONFIG_FILENAME,
28
+ DEFAULT_GOAL_FILENAME,
29
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/config.yaml",
30
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt",
31
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/config.yaml",
32
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt",
33
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/config.yaml",
34
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt",
35
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/config.yaml",
36
+ ".loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt",
37
+ ],
38
+ PM_PLANNER_DISPATCHER_TEMPLATE_NAME: [
39
+ ".gitignore",
40
+ ROOT_CONFIG_FILENAME,
41
+ DEFAULT_GOAL_FILENAME,
42
+ ".loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/config.yaml",
43
+ ".loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/prompt.txt",
44
+ ".loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/config.yaml",
45
+ ".loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt",
46
+ ],
47
+ }
48
+ PACKAGED_TEMPLATE_NAMES = list(PACKAGED_TEMPLATE_FILES_BY_NAME)
49
+ GITIGNORE_LINES = [".loopy_loop/sessions/"]
50
+ ROOT_CONFIG_TEMPLATE = f"""goal_file: "{DEFAULT_GOAL_FILENAME}"
51
+ workflow_set: "{MAIN_WORKFLOW_SET_NAME}"
52
+ max_turns: 20
53
+ goal_check_consecutive_failures_cap: 3
54
+ team_harness_provider: "codex"
55
+ team_harness_model: "gpt-5.5"
56
+ team_harness_agents:
57
+ - "codex"
58
+ - "claude"
59
+ - "gemini"
60
+ team_harness_agent_models:
61
+ codex: "gpt-5.5"
62
+ claude: "claude-opus-4-8"
63
+ gemini: "gemini-3.5-flash"
64
+ team_harness_agent_reasoning_efforts: {{}}
65
+ # Optional coordinator retry controls. Omit to use team-harness defaults.
66
+ # team_harness_max_retries: 8
67
+ # team_harness_retry_base_delay_s: 2.0
68
+ # team_harness_retry_max_delay_s: 60.0
69
+ team_harness_api_base: "https://openrouter.ai/api/v1"
70
+ team_harness_api_key_env: "OPENROUTER_API_KEY"
71
+ """
72
+ GOAL_TEMPLATE = """Ship a minimal working landing page
73
+ """
74
+ GOAL_CHECK_CONFIG_TEMPLATE = """enabled: true
75
+ run_every: 1
76
+ must_follow: null
77
+ not_before_iteration: 1
78
+ description: "Evaluate whether the loop goal is already satisfied."
79
+ """
80
+ GOAL_CHECK_PROMPT_TEMPLATE = """Evaluate whether the repo now satisfies the loopy-loop goal.
81
+
82
+ Write exactly one JSON file to the provided goal_check.json output path using:
83
+ {
84
+ "goal_met": false,
85
+ "reason": "brief explanation",
86
+ "schema_version": 1
87
+ }
88
+
89
+ If and only if goal_met is true, update the Session control path to stop the
90
+ loop using:
91
+ {
92
+ "state": "stopped",
93
+ "reason": "goal_check verified the loop goal is satisfied",
94
+ "stop_reason": "goal_met",
95
+ "schema_version": 1
96
+ }
97
+ """
98
+
99
+
100
+ @click.group()
101
+ def main() -> None:
102
+ """loopy-loop CLI."""
103
+
104
+
105
+ @main.command()
106
+ @click.option(
107
+ "--template",
108
+ "template_name",
109
+ type=click.Choice([DEFAULT_TEMPLATE_NAME, *PACKAGED_TEMPLATE_NAMES]),
110
+ default=DEFAULT_TEMPLATE_NAME,
111
+ show_default=True,
112
+ help="Initial workflow template to scaffold.",
113
+ )
114
+ def init(template_name: str) -> None:
115
+ """Initialize loopy-loop files."""
116
+ repo_root = Path.cwd()
117
+ if template_name == DEFAULT_TEMPLATE_NAME:
118
+ created = _init_default_template(repo_root=repo_root)
119
+ else:
120
+ created = _init_packaged_template(
121
+ repo_root=repo_root, template_name=template_name
122
+ )
123
+ _ensure_gitignore(repo_root=repo_root)
124
+
125
+ if created:
126
+ click.echo("Created:")
127
+ for path in created:
128
+ click.echo(f"- {path}")
129
+ else:
130
+ click.echo("loopy-loop is already initialized.")
131
+
132
+
133
+ def _init_default_template(*, repo_root: Path) -> list[str]:
134
+ loopy_dir = repo_root / LOOPY_DIRNAME
135
+ workflow_dir = (
136
+ loopy_dir
137
+ / "workflow_sets"
138
+ / MAIN_WORKFLOW_SET_NAME
139
+ / "workflows"
140
+ / GOAL_CHECK_WORKFLOW_ID
141
+ )
142
+ loopy_dir.mkdir(parents=True, exist_ok=True)
143
+ workflow_dir.mkdir(parents=True, exist_ok=True)
144
+
145
+ created: list[str] = []
146
+ created.extend(
147
+ _write_if_missing(
148
+ path=repo_root / ROOT_CONFIG_FILENAME, content=ROOT_CONFIG_TEMPLATE
149
+ )
150
+ )
151
+ created.extend(
152
+ _write_if_missing(path=repo_root / DEFAULT_GOAL_FILENAME, content=GOAL_TEMPLATE)
153
+ )
154
+ created.extend(
155
+ _write_if_missing(
156
+ path=workflow_dir / "config.yaml", content=GOAL_CHECK_CONFIG_TEMPLATE
157
+ )
158
+ )
159
+ created.extend(
160
+ _write_if_missing(
161
+ path=workflow_dir / "prompt.txt", content=GOAL_CHECK_PROMPT_TEMPLATE
162
+ )
163
+ )
164
+ return created
165
+
166
+
167
+ def _init_packaged_template(*, repo_root: Path, template_name: str) -> list[str]:
168
+ template_root = files("loopy_loop").joinpath("templates", template_name)
169
+ created: list[str] = []
170
+ for relative_path in PACKAGED_TEMPLATE_FILES_BY_NAME[template_name]:
171
+ created.extend(
172
+ _copy_template_file_if_missing(
173
+ source_root=template_root,
174
+ relative_path=relative_path,
175
+ repo_root=repo_root,
176
+ )
177
+ )
178
+ return created
179
+
180
+
181
+ def _copy_template_file_if_missing(
182
+ *, source_root: Traversable, relative_path: str, repo_root: Path
183
+ ) -> list[str]:
184
+ source = source_root.joinpath(*relative_path.split("/"))
185
+ return _write_if_missing(
186
+ path=repo_root / relative_path, content=source.read_text(encoding="utf-8")
187
+ )
188
+
189
+
190
+ @main.command()
191
+ @click.option("--host", default="0.0.0.0", show_default=True)
192
+ @click.option("--port", default=8080, show_default=True, type=int)
193
+ @click.option("--resume", is_flag=True, default=False)
194
+ @click.option(
195
+ "--workflow-set",
196
+ default=None,
197
+ help="Workflow set to run instead of the workflow_set in config.",
198
+ )
199
+ @click.option(
200
+ "--goal-file",
201
+ type=click.Path(path_type=Path, dir_okay=False),
202
+ default=None,
203
+ help="Goal file to copy into the new session as goal.md.",
204
+ )
205
+ def coordinator(
206
+ host: str, port: int, resume: bool, workflow_set: str | None, goal_file: Path | None
207
+ ) -> None:
208
+ """Run the coordinator server with exactly two endpoints: /register and /finished."""
209
+ repo_root = Path.cwd()
210
+ try:
211
+ app = create_coordinator_app(
212
+ repo_root=repo_root,
213
+ resume=resume,
214
+ workflow_set=workflow_set,
215
+ goal_file=goal_file,
216
+ )
217
+ except ConfigError as exc:
218
+ raise click.ClickException(str(exc)) from exc
219
+ uvicorn.run(app, host=host, port=port)
220
+
221
+
222
+ @main.command()
223
+ @click.option("--coordinator", "coordinator_url", required=True)
224
+ def worker(coordinator_url: str) -> None:
225
+ """Run a loopy-loop worker.
226
+
227
+ Calls /register once to get the first task, then loops calling /finished
228
+ after each completed task until it receives a stop response.
229
+ """
230
+ repo_root = Path.cwd()
231
+ run_worker_loop(repo_root=repo_root, coordinator_url=coordinator_url)
232
+
233
+
234
+ @main.command()
235
+ def status() -> None:
236
+ """Show loop status."""
237
+ repo_root = Path.cwd()
238
+ state = StateStore(repo_root=repo_root).read_state()
239
+ if state is None:
240
+ click.echo("No loopy-loop state found.")
241
+ return
242
+ click.echo(f"status: {state.status}")
243
+ click.echo(f"session: {state.active_session_id}")
244
+ click.echo(f"iteration_count: {state.iteration_count}")
245
+ if state.current_task is None:
246
+ click.echo("current_task: none")
247
+ else:
248
+ click.echo(
249
+ f"current_task: {state.current_task.workflow_id} "
250
+ f"(iteration {state.current_task.iteration}, "
251
+ f"session {state.current_task.session_id}, "
252
+ f"started {state.current_task.started_at})"
253
+ )
254
+ click.echo(f"stop_reason: {state.stop_reason or 'none'}")
255
+
256
+
257
+ @main.command()
258
+ def stop() -> None:
259
+ """Request loop stop."""
260
+ repo_root = Path.cwd()
261
+ store = StateStore(repo_root=repo_root)
262
+
263
+ def mutator(state: LoopState | None) -> tuple[LoopState, None]:
264
+ if state is None:
265
+ raise click.ClickException("No loopy-loop state found.")
266
+ state.stop_requested = True
267
+ return state, None
268
+
269
+ store.mutate(mutator)
270
+ click.echo("stop requested")
271
+
272
+
273
+ def _write_if_missing(*, path: Path, content: str) -> list[str]:
274
+ if path.exists():
275
+ return []
276
+ path.parent.mkdir(parents=True, exist_ok=True)
277
+ path.write_text(content, encoding="utf-8")
278
+ return [str(path)]
279
+
280
+
281
+ def _ensure_gitignore(*, repo_root: Path) -> None:
282
+ path = repo_root / ".gitignore"
283
+ existing_lines: list[str]
284
+ if path.exists():
285
+ existing_lines = path.read_text(encoding="utf-8").splitlines()
286
+ else:
287
+ existing_lines = []
288
+ updated_lines = list(existing_lines)
289
+ for line in GITIGNORE_LINES:
290
+ if line not in existing_lines:
291
+ updated_lines.append(line)
292
+ content = "\n".join(updated_lines).rstrip() + "\n"
293
+ path.write_text(content, encoding="utf-8")