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/config.py ADDED
@@ -0,0 +1,491 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+ from pydantic import computed_field
10
+ from pydantic import ConfigDict
11
+ from pydantic import Field
12
+ from pydantic import field_validator
13
+ from pydantic import model_validator
14
+ from pydantic import ValidationError
15
+ import yaml
16
+
17
+ ROOT_CONFIG_FILENAME = "loopy_loop_config.yaml"
18
+ DEFAULT_GOAL_FILENAME = "loopy_loop_goal.txt"
19
+ LOOPY_DIRNAME = ".loopy_loop"
20
+ WORKFLOWS_DIRNAME = "workflows"
21
+ WORKFLOW_SETS_DIRNAME = "workflow_sets"
22
+ GOAL_HASH_LENGTH = 12
23
+ DEFAULT_GOAL_CHECK_FAILURE_CAP = 3
24
+ DEFAULT_PROVIDER = "openai_compat"
25
+ PROVIDERS_WITHOUT_API_KEY: frozenset[str] = frozenset({"codex"})
26
+ DEFAULT_MODEL = "gpt-5.5"
27
+ DEFAULT_AGENTS = ["codex"]
28
+ DEFAULT_API_BASE = "https://openrouter.ai/api/v1"
29
+ DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY"
30
+ DEFAULT_SYSTEM_PROMPT_EXTENSION = ""
31
+ DEFAULT_WORKFLOW_ENABLED = True
32
+ DEFAULT_WORKFLOW_RUN_EVERY = 1
33
+ DEFAULT_WORKFLOW_MUST_FOLLOW: str | None = None
34
+ DEFAULT_WORKFLOW_NOT_BEFORE_ITERATION = 0
35
+ DEFAULT_WORKFLOW_DESCRIPTION = ""
36
+ DEFAULT_WORKFLOW_PRIORITY = 0
37
+ DEFAULT_WORKFLOW_RUN_ON_START = False
38
+ DEFAULT_WORKFLOW_EMITS_GOAL_CHECK = False
39
+
40
+
41
+ class ConfigError(Exception):
42
+ """Raised when config loading or validation fails."""
43
+
44
+
45
+ class RootConfig(BaseModel):
46
+ """Repo-level loop configuration loaded from loopy_loop_config.yaml."""
47
+
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ goal: str = Field(
51
+ description="Natural-language goal the loop is trying to satisfy."
52
+ )
53
+ workflow_set: str = Field(
54
+ ...,
55
+ description="Workflow set used when the coordinator is started without an override.",
56
+ )
57
+ completion_criteria: list[str] = Field(
58
+ default_factory=list,
59
+ description="Observable criteria used by workflows and goal checks.",
60
+ )
61
+ stop_criteria: list[str] = Field(
62
+ default_factory=list,
63
+ description="Conditions that should stop the loop before the goal is met.",
64
+ )
65
+ max_turns: int = Field(
66
+ ..., description="Maximum number of completed workflow iterations."
67
+ )
68
+ goal_check_consecutive_failures_cap: int = Field(
69
+ default=DEFAULT_GOAL_CHECK_FAILURE_CAP,
70
+ ge=1,
71
+ description="Consecutive invalid goal-check outputs allowed before failure.",
72
+ )
73
+ team_harness_provider: str = Field(
74
+ default=DEFAULT_PROVIDER,
75
+ description="team-harness provider name used by workers.",
76
+ )
77
+ team_harness_model: str = Field(
78
+ default=DEFAULT_MODEL, description="Model name passed to team-harness."
79
+ )
80
+ team_harness_agents: list[str] = Field(
81
+ default_factory=lambda: list(DEFAULT_AGENTS),
82
+ description="Agent names team-harness should make available.",
83
+ )
84
+ team_harness_agent_models: dict[str, str] = Field(
85
+ default_factory=dict,
86
+ description=(
87
+ "Per-agent worker model overrides passed to team-harness, keyed by "
88
+ "agent name such as codex or gemini."
89
+ ),
90
+ )
91
+ team_harness_agent_reasoning_efforts: dict[str, str] = Field(
92
+ default_factory=dict,
93
+ description=(
94
+ "Per-agent reasoning-effort overrides passed to team-harness, keyed "
95
+ "by agent name. Only agents whose templates support a reasoning "
96
+ "effort flag will use this value."
97
+ ),
98
+ )
99
+ team_harness_max_retries: int | None = Field(
100
+ default=None,
101
+ ge=0,
102
+ description=(
103
+ "Optional coordinator retry budget passed to team-harness. Leave null "
104
+ "to use the installed team-harness default."
105
+ ),
106
+ )
107
+ team_harness_retry_base_delay_s: float | None = Field(
108
+ default=None,
109
+ gt=0,
110
+ description=(
111
+ "Optional base delay in seconds for team-harness coordinator retry backoff."
112
+ ),
113
+ )
114
+ team_harness_retry_max_delay_s: float | None = Field(
115
+ default=None,
116
+ gt=0,
117
+ description=(
118
+ "Optional maximum delay in seconds for team-harness coordinator retry "
119
+ "backoff."
120
+ ),
121
+ )
122
+ team_harness_api_base: str = Field(
123
+ default=DEFAULT_API_BASE,
124
+ description="OpenAI-compatible API base URL passed to team-harness.",
125
+ )
126
+ team_harness_api_key_env: str = Field(
127
+ default=DEFAULT_API_KEY_ENV,
128
+ description="Environment variable name containing the API key.",
129
+ )
130
+ team_harness_system_prompt_extension: str = Field(
131
+ default=DEFAULT_SYSTEM_PROMPT_EXTENSION,
132
+ description="Additional system prompt text appended for every harness run.",
133
+ )
134
+
135
+ @computed_field
136
+ @property
137
+ def goal_hash(self) -> str:
138
+ return derive_goal_hash(goal=self.goal)
139
+
140
+ @field_validator(
141
+ "team_harness_agent_models", "team_harness_agent_reasoning_efforts"
142
+ )
143
+ @classmethod
144
+ def validate_non_empty_string_mapping(cls, value: dict[str, str]) -> dict[str, str]:
145
+ for key, item in value.items():
146
+ if not key.strip():
147
+ raise ValueError("mapping keys must not be empty")
148
+ if not item.strip():
149
+ raise ValueError(f"mapping value for {key!r} must not be empty")
150
+ return value
151
+
152
+ @field_validator("workflow_set")
153
+ @classmethod
154
+ def validate_workflow_set(cls, value: str) -> str:
155
+ if not value.strip():
156
+ raise ValueError("workflow_set must not be empty")
157
+ return value
158
+
159
+ @field_validator("team_harness_api_base")
160
+ @classmethod
161
+ def normalize_api_base_value(cls, value: str) -> str:
162
+ return normalize_api_base(value=value)
163
+
164
+ @model_validator(mode="after")
165
+ def validate_retry_delay_bounds(self) -> "RootConfig":
166
+ if (
167
+ self.team_harness_retry_base_delay_s is not None
168
+ and self.team_harness_retry_max_delay_s is not None
169
+ and self.team_harness_retry_max_delay_s
170
+ < self.team_harness_retry_base_delay_s
171
+ ):
172
+ raise ValueError(
173
+ "team_harness_retry_max_delay_s must be greater than or equal to "
174
+ "team_harness_retry_base_delay_s"
175
+ )
176
+ return self
177
+
178
+
179
+ class RunAfterSuccesses(BaseModel):
180
+ """Cadence rule that unlocks a workflow after successful runs of another."""
181
+
182
+ model_config = ConfigDict(extra="forbid")
183
+
184
+ workflow_id: str = Field(
185
+ ..., description="Workflow id whose successful runs drive this cadence."
186
+ )
187
+ every: int = Field(
188
+ ..., ge=1, description="Run after this many new successful target runs."
189
+ )
190
+
191
+
192
+ class WorkflowConfig(BaseModel):
193
+ """Per-workflow scheduling and execution configuration."""
194
+
195
+ model_config = ConfigDict(extra="forbid")
196
+
197
+ enabled: bool = Field(
198
+ default=DEFAULT_WORKFLOW_ENABLED,
199
+ description="Whether this workflow can be scheduled.",
200
+ )
201
+ run_every: int = Field(
202
+ default=DEFAULT_WORKFLOW_RUN_EVERY,
203
+ ge=1,
204
+ description="Minimum completed iterations between runs of this workflow.",
205
+ )
206
+ must_follow: str | None = Field(
207
+ default=DEFAULT_WORKFLOW_MUST_FOLLOW,
208
+ description="Required immediately previous successful workflow id.",
209
+ )
210
+ not_before_iteration: int = Field(
211
+ default=DEFAULT_WORKFLOW_NOT_BEFORE_ITERATION,
212
+ ge=0,
213
+ description="Earliest completed iteration count where workflow is eligible.",
214
+ )
215
+ description: str = Field(
216
+ default=DEFAULT_WORKFLOW_DESCRIPTION,
217
+ description="Human-readable purpose of this workflow.",
218
+ )
219
+ priority: int = Field(
220
+ default=DEFAULT_WORKFLOW_PRIORITY,
221
+ description="Tie-breaker among eligible workflows; higher runs first.",
222
+ )
223
+ run_on_start: bool = Field(
224
+ default=DEFAULT_WORKFLOW_RUN_ON_START,
225
+ description="Allow this workflow before any successful workflow has run.",
226
+ )
227
+ run_after_successes: RunAfterSuccesses | None = Field(
228
+ default=None,
229
+ description="Optional cadence based on successful runs of another workflow.",
230
+ )
231
+ emits_goal_check: bool = Field(
232
+ default=DEFAULT_WORKFLOW_EMITS_GOAL_CHECK,
233
+ description="Whether this workflow is expected to write goal_check.json.",
234
+ )
235
+
236
+
237
+ class WorkflowDefinition(WorkflowConfig):
238
+ """Resolved workflow config plus its id and on-disk file locations."""
239
+
240
+ workflow_set: str = Field(...)
241
+ id: str = Field(...)
242
+ directory: Path = Field(...)
243
+ prompt_path: Path = Field(...)
244
+ config_path: Path = Field(...)
245
+
246
+
247
+ class PreflightResult(BaseModel):
248
+ """Validated root config and workflow definitions captured at startup."""
249
+
250
+ root_config: RootConfig
251
+ workflow_set: str
252
+ workflows: list[WorkflowDefinition]
253
+
254
+
255
+ def normalize_api_base(*, value: str) -> str:
256
+ base = value.rstrip("/")
257
+ return base if base.endswith("/v1") else f"{base}/v1"
258
+
259
+
260
+ def derive_goal_hash(*, goal: str) -> str:
261
+ return hashlib.sha256(goal.encode("utf-8")).hexdigest()[:GOAL_HASH_LENGTH]
262
+
263
+
264
+ def load_root_config(*, repo_root: Path, goal_file: Path | None = None) -> RootConfig:
265
+ config_path = repo_root / ROOT_CONFIG_FILENAME
266
+ data = _read_yaml_mapping(path=config_path)
267
+ if goal_file is not None:
268
+ data["goal_file"] = str(goal_file)
269
+ data = _resolve_root_config_goal(data=data, config_path=config_path)
270
+ try:
271
+ return RootConfig.model_validate(data)
272
+ except ValidationError as exc:
273
+ raise ConfigError(f"Invalid root config at {config_path}: {exc}") from exc
274
+
275
+
276
+ def load_workflow_config(*, workflow_dir: Path) -> WorkflowConfig:
277
+ config_path = workflow_dir / "config.yaml"
278
+ data = _read_yaml_mapping(path=config_path)
279
+ try:
280
+ return WorkflowConfig.model_validate(data)
281
+ except ValidationError as exc:
282
+ raise ConfigError(f"Invalid workflow config at {config_path}: {exc}") from exc
283
+
284
+
285
+ def workflow_set_dir_path(*, repo_root: Path, workflow_set: str) -> Path:
286
+ return repo_root / LOOPY_DIRNAME / WORKFLOW_SETS_DIRNAME / workflow_set
287
+
288
+
289
+ def workflow_set_workflows_dir_path(*, repo_root: Path, workflow_set: str) -> Path:
290
+ return workflow_set_dir_path(repo_root=repo_root, workflow_set=workflow_set) / (
291
+ WORKFLOWS_DIRNAME
292
+ )
293
+
294
+
295
+ def load_workflow_definitions(
296
+ *, repo_root: Path, workflow_set: str
297
+ ) -> list[WorkflowDefinition]:
298
+ selected_workflow_set = workflow_set
299
+ workflows_dir = workflow_set_workflows_dir_path(
300
+ repo_root=repo_root, workflow_set=selected_workflow_set
301
+ )
302
+ if not workflows_dir.exists():
303
+ return []
304
+ definitions: list[WorkflowDefinition] = []
305
+ for workflow_dir in sorted(
306
+ path for path in workflows_dir.iterdir() if path.is_dir()
307
+ ):
308
+ config = load_workflow_config(workflow_dir=workflow_dir)
309
+ prompt_path = workflow_dir / "prompt.txt"
310
+ config_path = workflow_dir / "config.yaml"
311
+ try:
312
+ definition = WorkflowDefinition.model_validate(
313
+ {
314
+ **config.model_dump(),
315
+ "workflow_set": selected_workflow_set,
316
+ "id": workflow_dir.name,
317
+ "directory": workflow_dir,
318
+ "prompt_path": prompt_path,
319
+ "config_path": config_path,
320
+ }
321
+ )
322
+ except ValidationError as exc:
323
+ raise ConfigError(
324
+ f"Invalid workflow definition for {workflow_dir.name}: {exc}"
325
+ ) from exc
326
+ definitions.append(definition)
327
+ return definitions
328
+
329
+
330
+ def validate_workflow_graph(*, workflows: list[WorkflowDefinition]) -> None:
331
+ workflow_ids = {workflow.id for workflow in workflows}
332
+ unresolved: list[str] = []
333
+ for workflow in workflows:
334
+ if (
335
+ workflow.must_follow is not None
336
+ and workflow.must_follow not in workflow_ids
337
+ ):
338
+ unresolved.append(
339
+ f"{workflow.id}: must_follow references missing workflow "
340
+ f"'{workflow.must_follow}'"
341
+ )
342
+ if workflow.run_after_successes is None:
343
+ continue
344
+ if workflow.run_after_successes.workflow_id not in workflow_ids:
345
+ unresolved.append(
346
+ f"{workflow.id}: run_after_successes references missing workflow "
347
+ f"'{workflow.run_after_successes.workflow_id}'"
348
+ )
349
+ if unresolved:
350
+ joined = "\n".join(unresolved)
351
+ raise ConfigError(f"Workflow graph validation failed:\n{joined}")
352
+
353
+
354
+ def resolve_api_key(*, config: RootConfig) -> str | None:
355
+ if config.team_harness_provider in PROVIDERS_WITHOUT_API_KEY:
356
+ return None
357
+ value = os.environ.get(config.team_harness_api_key_env)
358
+ if not value:
359
+ raise ConfigError(
360
+ f"Missing required environment variable: {config.team_harness_api_key_env}"
361
+ )
362
+ return value
363
+
364
+
365
+ def run_preflight(
366
+ *, repo_root: Path, workflow_set: str | None = None, goal_file: Path | None = None
367
+ ) -> PreflightResult:
368
+ errors: list[str] = []
369
+ root_config: RootConfig | None = None
370
+ workflows: list[WorkflowDefinition] = []
371
+ selected_workflow_set = workflow_set
372
+
373
+ try:
374
+ root_config = load_root_config(repo_root=repo_root, goal_file=goal_file)
375
+ selected_workflow_set = workflow_set or root_config.workflow_set
376
+ root_config = root_config.model_copy(
377
+ update={"workflow_set": selected_workflow_set}
378
+ )
379
+ except ConfigError as exc:
380
+ errors.append(str(exc))
381
+
382
+ if selected_workflow_set is None:
383
+ errors.append("No workflow_set specified in config or CLI override")
384
+ else:
385
+ try:
386
+ workflows = load_workflow_definitions(
387
+ repo_root=repo_root, workflow_set=selected_workflow_set
388
+ )
389
+ except ConfigError as exc:
390
+ errors.append(str(exc))
391
+
392
+ if workflows:
393
+ for workflow in workflows:
394
+ if not workflow.prompt_path.is_file():
395
+ errors.append(f"Missing workflow prompt: {workflow.prompt_path}")
396
+ if not workflow.config_path.is_file():
397
+ errors.append(f"Missing workflow config: {workflow.config_path}")
398
+
399
+ if root_config is not None:
400
+ try:
401
+ validate_workflow_graph(workflows=workflows)
402
+ except ConfigError as exc:
403
+ errors.append(str(exc))
404
+ try:
405
+ resolve_api_key(config=root_config)
406
+ except ConfigError as exc:
407
+ errors.append(str(exc))
408
+
409
+ if not workflows:
410
+ if selected_workflow_set is None:
411
+ errors.append("No workflows found because no workflow_set was specified")
412
+ else:
413
+ legacy_workflows_dir = repo_root / LOOPY_DIRNAME / WORKFLOWS_DIRNAME
414
+ if legacy_workflows_dir.exists():
415
+ errors.append(
416
+ "Legacy workflow directory is not supported at runtime: "
417
+ f"{legacy_workflows_dir}. Move workflows under "
418
+ f"{repo_root / LOOPY_DIRNAME / WORKFLOW_SETS_DIRNAME}/"
419
+ "<workflow_set>/workflows/"
420
+ )
421
+ errors.append(
422
+ "No workflows found under "
423
+ f"{workflow_set_workflows_dir_path(repo_root=repo_root, workflow_set=selected_workflow_set)}"
424
+ )
425
+
426
+ if errors:
427
+ joined = "\n".join(f"- {error}" for error in errors)
428
+ raise ConfigError(f"Preflight failed:\n{joined}")
429
+
430
+ assert root_config is not None
431
+ assert selected_workflow_set is not None
432
+ return PreflightResult(
433
+ root_config=root_config, workflow_set=selected_workflow_set, workflows=workflows
434
+ )
435
+
436
+
437
+ def load_workflow_prompt(*, workflow: WorkflowDefinition) -> str:
438
+ try:
439
+ return workflow.prompt_path.read_text(encoding="utf-8")
440
+ except OSError as exc:
441
+ raise ConfigError(
442
+ f"Unable to read workflow prompt at {workflow.prompt_path}: {exc}"
443
+ ) from exc
444
+
445
+
446
+ def _read_yaml_mapping(*, path: Path) -> dict[str, Any]:
447
+ try:
448
+ raw = path.read_text(encoding="utf-8")
449
+ except OSError as exc:
450
+ raise ConfigError(f"Unable to read config file at {path}: {exc}") from exc
451
+ try:
452
+ data = yaml.safe_load(raw)
453
+ except yaml.YAMLError as exc:
454
+ raise ConfigError(f"Invalid YAML at {path}: {exc}") from exc
455
+ if data is None:
456
+ return {}
457
+ if not isinstance(data, dict):
458
+ raise ConfigError(f"Expected mapping at {path}")
459
+ return data
460
+
461
+
462
+ def _resolve_root_config_goal(
463
+ *, data: dict[str, Any], config_path: Path
464
+ ) -> dict[str, Any]:
465
+ if "goal" in data:
466
+ raise ConfigError(
467
+ f"Invalid root config at {config_path}: field 'goal' is not supported; "
468
+ "use 'goal_file' instead"
469
+ )
470
+ raw_goal_file = data.get("goal_file")
471
+ if raw_goal_file is None:
472
+ raise ConfigError(
473
+ f"Invalid root config at {config_path}: missing required field 'goal_file'"
474
+ )
475
+ if not isinstance(raw_goal_file, str) or not raw_goal_file.strip():
476
+ raise ConfigError(
477
+ f"Invalid root config at {config_path}: goal_file must be a non-empty string"
478
+ )
479
+ goal_path = Path(raw_goal_file).expanduser()
480
+ if not goal_path.is_absolute():
481
+ goal_path = config_path.parent / goal_path
482
+ try:
483
+ goal = goal_path.read_text(encoding="utf-8").strip()
484
+ except OSError as exc:
485
+ raise ConfigError(f"Unable to read goal file at {goal_path}: {exc}") from exc
486
+ if not goal:
487
+ raise ConfigError(f"Goal file at {goal_path} must not be empty")
488
+ resolved = dict(data)
489
+ resolved.pop("goal_file")
490
+ resolved["goal"] = goal
491
+ return resolved