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.
- loopy_loop/__init__.py +1 -0
- loopy_loop/cli.py +293 -0
- loopy_loop/config.py +491 -0
- loopy_loop/coordinator_app.py +706 -0
- loopy_loop/harness_runner.py +195 -0
- loopy_loop/models.py +163 -0
- loopy_loop/scheduler.py +232 -0
- loopy_loop/sessions.py +313 -0
- loopy_loop/state_store.py +110 -0
- loopy_loop/templates/inner_outer_eval/.gitignore +4 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/config.yaml +11 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt +97 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/config.yaml +12 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt +86 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/config.yaml +8 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt +223 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/config.yaml +8 -0
- loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt +333 -0
- loopy_loop/templates/inner_outer_eval/loopy_loop_config.yaml +45 -0
- loopy_loop/templates/inner_outer_eval/loopy_loop_goal.txt +5 -0
- loopy_loop/templates/pm_planner_dispatcher/.gitignore +1 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/config.yaml +8 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt +78 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/config.yaml +9 -0
- loopy_loop/templates/pm_planner_dispatcher/.loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/planner/prompt.txt +99 -0
- loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml +24 -0
- loopy_loop/templates/pm_planner_dispatcher/loopy_loop_goal.txt +1 -0
- loopy_loop/worker.py +265 -0
- loopy_loop-0.2.0.dist-info/METADATA +408 -0
- loopy_loop-0.2.0.dist-info/RECORD +33 -0
- loopy_loop-0.2.0.dist-info/WHEEL +4 -0
- loopy_loop-0.2.0.dist-info/entry_points.txt +3 -0
- 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")
|