gooseloop 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.
gooseloop/looper.py ADDED
@@ -0,0 +1,581 @@
1
+ """GooseLooper — drive an Engine's Pipeline for one pass.
2
+
3
+ Order per ADR 0006:
4
+
5
+ 1. Build Context (model, session dir, base_env, environment).
6
+ 2. engine.precheck(ctx). Raise aborts the pass.
7
+ 3. pipeline = engine.pipeline(ctx). Must be a Pipeline dataclass.
8
+ 4. Run review FIRST. The framework wraps the engine's review post_process
9
+ to also parse the deliverable JSON, validate the schema, build
10
+ child phases from routing[] via engine.branch_policies, and seed
11
+ the operator_actions ledger from the review's payload.
12
+ 5. Drain the body queue. Review-spawned children sit at HEAD; engine
13
+ cadence phases follow. Body phase post_process may enqueue more.
14
+ 6. Run summary LAST, with the full ledger and outputs available via ctx.
15
+ 7. Print session footer.
16
+
17
+ The looper knows nothing about prospects, scoring, or any engine concept.
18
+ """
19
+
20
+ import dataclasses
21
+ import os
22
+ import sys
23
+ import time
24
+ from collections import deque
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Optional
27
+
28
+ from .branch_policy import BranchPolicy
29
+ from .config import LooperConfig
30
+ from .engine import Engine
31
+ from .environment import Environment
32
+ from .footer import print_session_footer
33
+ from .goose import run_goose_with_retry
34
+ from .phase import Context, Phase, Pipeline
35
+ from .predicates import file_nonempty
36
+ from .protocol import (
37
+ PROTOCOL_VERSION,
38
+ OperatorAction,
39
+ ProtocolVersionError,
40
+ ReviewOutput,
41
+ RoutingEntry,
42
+ validate_review,
43
+ )
44
+ from .session import log_step, new_session
45
+ from .extract import extract_json_with_provenance
46
+ from .text import Color, banner, colored
47
+
48
+
49
+ class GooseLooper:
50
+ """Execution shell for an Engine's Pipeline.
51
+
52
+ Usage:
53
+ GooseLooper(engine=MyEngine(), environment=MyEnv()).begin_loop()
54
+
55
+ Parameters:
56
+ engine: the Engine instance driving this pass.
57
+ environment: the Environment instance (None = engine recipes that
58
+ never reference env_method are still runnable).
59
+ config: LooperConfig instance. None = LooperConfig.load() from cwd.
60
+ model: override the model. Falls back to engine.default_model(),
61
+ then to config.default_model.
62
+ save: write a session folder (default True).
63
+ validate: run engine.precheck() before the pipeline (default True).
64
+ review_only: stop after the review phase (default False).
65
+ review_overlays: list of CLI overlay paths for the review recipe.
66
+ summary_overlays: list of CLI overlay paths for the summary recipe.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ engine: Engine,
72
+ *,
73
+ environment: Optional[Environment] = None,
74
+ config: Optional[LooperConfig] = None,
75
+ model: Optional[str] = None,
76
+ save: bool = True,
77
+ validate: bool = True,
78
+ review_only: bool = False,
79
+ review_overlays: Optional[list[Path]] = None,
80
+ summary_overlays: Optional[list[Path]] = None,
81
+ ):
82
+ self.engine = engine
83
+ self.environment = environment
84
+ self.config = config or LooperConfig.load()
85
+ self.model = model or engine.default_model() or self.config.default_model
86
+ self.save = save
87
+ self.validate = validate
88
+ self.review_only = review_only
89
+ self.review_overlays = review_overlays or []
90
+ self.summary_overlays = summary_overlays or []
91
+
92
+ # ------------------------------------------------------------------
93
+ # public entry
94
+
95
+ def begin_loop(self) -> dict[str, Any]:
96
+ """Run one pipeline pass. Returns accounting summary."""
97
+ runner_start = time.perf_counter()
98
+ goose_calls = 0
99
+ actions_ran = 0
100
+ actions_skipped = 0
101
+
102
+ session_dir = (new_session(self.config.sessions_dir, self.model, self.engine.name)
103
+ if self.save else None)
104
+
105
+ env_paths = self.environment.env_vars() if self.environment else {}
106
+ ctx = Context(
107
+ model=self.model,
108
+ session_dir=session_dir,
109
+ base_env={**env_paths, **self.engine.base_env()},
110
+ environment=self.environment,
111
+ )
112
+
113
+ if self.validate:
114
+ banner(f"{self.engine.name}: precheck", Color.CYAN)
115
+ try:
116
+ self.engine.precheck(ctx)
117
+ except Exception as e:
118
+ print(colored(f"\nPrecheck failed: {e}", Color.RED), file=sys.stderr)
119
+ raise
120
+
121
+ pipeline = self.engine.pipeline(ctx)
122
+ if not isinstance(pipeline, Pipeline):
123
+ raise TypeError(
124
+ f"Engine.pipeline() must return Pipeline, got {type(pipeline).__name__}"
125
+ )
126
+
127
+ # Step + planned counters. Methods bump them; banners read them
128
+ # to render `[step/planned]` progress in the phase header.
129
+ self._step = 0
130
+ self._planned = 1 + len(pipeline.body) + (1 if pipeline.summary else 0)
131
+
132
+ body_queue: deque[Phase] = deque()
133
+
134
+ # --- 1. review ------------------------------------------------
135
+ review_status = "done"
136
+ review_output: ReviewOutput | None = None
137
+
138
+ try:
139
+ review_calls, review_ok, review_status, review_output = self._run_review(
140
+ pipeline.review, ctx, body_queue,
141
+ )
142
+ goose_calls += review_calls
143
+ # Children the review spawned via routing[] need to count as
144
+ # planned work. Read straight from the queue before any cadence
145
+ # phases get appended.
146
+ self._planned += len(body_queue)
147
+ if review_ok:
148
+ actions_ran += 1
149
+ else:
150
+ actions_skipped += 1
151
+ except Exception as e:
152
+ print(colored(
153
+ f"\nReview failed: {type(e).__name__}: {e}",
154
+ Color.RED,
155
+ ), file=sys.stderr)
156
+ if os.environ.get("GOOSELOOP_DEBUG"):
157
+ import traceback
158
+ traceback.print_exc(file=sys.stderr)
159
+ actions_skipped += 1
160
+ review_status = "error"
161
+
162
+ # --- 2. body --------------------------------------------------
163
+ if self.review_only:
164
+ if session_dir:
165
+ log_step(session_dir, "review-only: skipping body and summary")
166
+ elif review_status == "error":
167
+ if session_dir:
168
+ log_step(session_dir, "review status=error: skipping body and summary")
169
+ actions_skipped += len(pipeline.body) + (1 if pipeline.summary else 0)
170
+ else:
171
+ # Engine-declared cadence phases follow review-spawned children.
172
+ body_queue.extend(pipeline.body)
173
+ body_calls, body_ran, body_skipped = self._drain_body(body_queue, ctx)
174
+ goose_calls += body_calls
175
+ actions_ran += body_ran
176
+ actions_skipped += body_skipped
177
+
178
+ # --- 3. summary -------------------------------------------
179
+ if pipeline.summary is not None and review_status != "partial":
180
+ try:
181
+ summary_calls, summary_ok = self._run_phase(
182
+ pipeline.summary, ctx,
183
+ overlays=self.summary_overlays,
184
+ is_summary=True,
185
+ )
186
+ goose_calls += summary_calls
187
+ if summary_ok:
188
+ actions_ran += 1
189
+ else:
190
+ actions_skipped += 1
191
+ except RuntimeError as e:
192
+ print(colored(f"\nSummary failed: {e}", Color.RED), file=sys.stderr)
193
+ actions_skipped += 1
194
+ elif pipeline.summary is not None and review_status == "partial":
195
+ if session_dir:
196
+ log_step(session_dir, "review status=partial: skipping summary")
197
+ actions_skipped += 1
198
+
199
+ print_session_footer(
200
+ elapsed=time.perf_counter() - runner_start,
201
+ goose_calls=goose_calls,
202
+ actions_planned=self._planned,
203
+ actions_ran=actions_ran,
204
+ actions_skipped=actions_skipped,
205
+ outputs=list(ctx.artifacts.get("outputs_written", [])),
206
+ operator_actions=list(ctx.artifacts.get("operator_actions", [])),
207
+ session_dir=session_dir,
208
+ )
209
+
210
+ return {
211
+ "goose_calls": goose_calls,
212
+ "actions_planned": self._planned,
213
+ "actions_ran": actions_ran,
214
+ "actions_skipped": actions_skipped,
215
+ "outputs": list(ctx.artifacts.get("outputs_written", [])),
216
+ "operator_actions": list(ctx.artifacts.get("operator_actions", [])),
217
+ "review_status": review_status,
218
+ "review_output": review_output,
219
+ "session_dir": session_dir,
220
+ }
221
+
222
+ # ------------------------------------------------------------------
223
+ # progress
224
+
225
+ def _announce(self, phase_name: str, *,
226
+ total: str | None = None,
227
+ color: str = Color.MAGENTA) -> None:
228
+ """Bump the step counter and print the phase banner with `[N/M]`.
229
+
230
+ N is monotonically increasing across the whole pass (review +
231
+ body + summary). M is the current planned total, which grows
232
+ when review-spawned or body-spawned children land in the queue.
233
+ Mid-run growth of M is expected: a `[3/5]` followed by `[4/7]`
234
+ means the previous phase spawned two more.
235
+
236
+ `total` overrides the M display when the caller knows the
237
+ planned count is structurally unknowable at this point — the
238
+ review phase, specifically, runs *before* its routing[] spawns
239
+ body children, so the framework can't truthfully claim a total
240
+ for it. Review calls with total="?".
241
+ """
242
+ self._step += 1
243
+ total_display = total if total is not None else str(self._planned)
244
+ # · separator survives banner()'s word-rewrap (it splits on
245
+ # whitespace and rejoins with single spaces); double-space would
246
+ # collapse, leaving "review [1/2]" instead of the intended layout.
247
+ banner(
248
+ f"{self.engine.name}: {phase_name} · [{self._step}/{total_display}]",
249
+ color,
250
+ )
251
+
252
+ # ------------------------------------------------------------------
253
+ # review
254
+
255
+ def _run_review(self, review: Phase, ctx: Context,
256
+ body_queue: deque[Phase]) -> tuple[int, bool, str, ReviewOutput | None]:
257
+ """Run review; parse output; seed ledger; spawn body children.
258
+
259
+ Returns (goose_calls, succeeded, status, parsed_output).
260
+ """
261
+ # Review's planned total is structurally unknown: routing[] hasn't
262
+ # run yet. Display "?" instead of a misleading partial count.
263
+ self._announce(review.name, total="?")
264
+ # Wrap the review's success_predicate so the retry loop fails any
265
+ # attempt that didn't emit parseable wrapped JSON. Without this,
266
+ # a mid-stream truncation (e.g. provider stream decode error) gets
267
+ # accepted as "success" by the default transient-error check, the
268
+ # downstream parse fails, and retries are off the table by then.
269
+ # Engines that explicitly set their own predicate keep it.
270
+ review_with_guard = (
271
+ review if review.success_predicate is not None
272
+ else dataclasses.replace(review, success_predicate=_review_output_parseable)
273
+ )
274
+ output = self._invoke_recipe(review_with_guard, ctx, overlays=self.review_overlays)
275
+ if output is None:
276
+ return 0, False, "error", None
277
+
278
+ extracted = extract_json_with_provenance(output)
279
+ if extracted is None:
280
+ print(colored(
281
+ "Review did not emit recognisable wrapped JSON; cannot parse.",
282
+ Color.RED,
283
+ ), file=sys.stderr)
284
+ if ctx.session_dir:
285
+ log_step(ctx.session_dir, "review: no wrapped JSON found by any recognizer")
286
+ return 1, False, "error", None
287
+
288
+ if not extracted.is_canonical:
289
+ msg = (
290
+ f"review parsed via {extracted.recognizer} (non-canonical wrapper). "
291
+ f"Tighten the review recipe to use <<<DELIVERABLE_JSON>>> / "
292
+ f"<<<END_DELIVERABLE>>> for stable runs."
293
+ )
294
+ print(colored(msg, Color.YELLOW), file=sys.stderr)
295
+ if ctx.session_dir:
296
+ log_step(ctx.session_dir, f"review: {msg}")
297
+ ctx.add_operator_action(
298
+ action="tighten review recipe to canonical sentinels",
299
+ why=f"review parsed via {extracted.recognizer}; "
300
+ f"weaker models will drift further if the recipe stays ambiguous",
301
+ recognizer=extracted.recognizer,
302
+ )
303
+
304
+ try:
305
+ review_output = validate_review(extracted.payload)
306
+ except ProtocolVersionError as e:
307
+ print(colored(f"Review protocol mismatch: {e}", Color.RED), file=sys.stderr)
308
+ if ctx.session_dir:
309
+ log_step(ctx.session_dir, f"review: protocol mismatch ({e})")
310
+ return 1, False, "error", None
311
+ except ValueError as e:
312
+ print(colored(f"Review schema invalid: {e}", Color.RED), file=sys.stderr)
313
+ if ctx.session_dir:
314
+ log_step(ctx.session_dir, f"review: schema invalid ({e})")
315
+ return 1, False, "error", None
316
+
317
+ # Stash the full payload for engine extensions to read.
318
+ ctx.artifacts["review_output"] = dict(review_output)
319
+ ctx.artifacts["review_routing"] = list(review_output.get("routing", []))
320
+
321
+ # Seed the ledger from the review's operator_actions. validate_review
322
+ # already normalised entries to {action: str, why: str, ...extras}, so
323
+ # the loop trusts that shape and just guards against an empty action.
324
+ for entry in review_output.get("operator_actions", []):
325
+ action = entry.get("action", "")
326
+ why = entry.get("why", "")
327
+ if not action:
328
+ continue
329
+ extras = {k: v for k, v in entry.items() if k not in ("action", "why")}
330
+ ctx.add_operator_action(action=action, why=why, **extras)
331
+
332
+ # Persist the review JSON to the session for downstream phases.
333
+ if ctx.session_dir:
334
+ actions_dir = ctx.session_dir / "actions"
335
+ actions_dir.mkdir(parents=True, exist_ok=True)
336
+ review_path = actions_dir / "review.json"
337
+ import json
338
+ review_path.write_text(json.dumps(review_output, indent=2))
339
+ ctx.base_env["REVIEW_JSON_PATH"] = str(review_path)
340
+ log_step(ctx.session_dir, f"review: wrote {review_path}")
341
+
342
+ # Spawn body children from routing[].
343
+ status = str(review_output.get("status", "done"))
344
+ if status == "done":
345
+ children = self._build_body_phases(review_output.get("routing", []))
346
+ body_queue.extend(children)
347
+ elif status == "partial":
348
+ if ctx.session_dir:
349
+ log_step(ctx.session_dir, "review status=partial: skipping routing")
350
+
351
+ # Engine post_process for the review (if any) runs after parsing.
352
+ if review.post_process is not None:
353
+ extra_children = review.post_process(output, ctx)
354
+ if extra_children:
355
+ body_queue.extend(extra_children)
356
+
357
+ return 1, True, status, review_output
358
+
359
+ def _build_body_phases(self, routing: list[RoutingEntry]) -> list[Phase]:
360
+ """Build body Phases from review routing entries via BranchPolicy."""
361
+ out: list[Phase] = []
362
+ for entry in routing:
363
+ recipe = entry.get("recipe")
364
+ if not isinstance(recipe, str):
365
+ continue
366
+ params: dict[str, Any] = entry.get("params") or {}
367
+ policy = self.engine.branch_policies.get(recipe, BranchPolicy())
368
+ phase = self._phase_from_routing(recipe, params, policy)
369
+ out.append(phase)
370
+ return out
371
+
372
+ def _phase_from_routing(self, recipe: str, params: dict[str, Any],
373
+ policy: BranchPolicy) -> Phase:
374
+ recipe_path = self._resolve_recipe_path(recipe)
375
+ param_env = _params_to_env(params)
376
+
377
+ # If the policy can compute an output path, inject it as OUTPUT_PATH
378
+ # so the recipe writes to exactly the file the predicate later checks.
379
+ # Without this the recipe and the predicate could (and did) disagree
380
+ # on filenames — recipe wrote ${SHA}.md, predicate looked for
381
+ # <slug>-<sha8>.md, every successful write triggered a fake "transient
382
+ # error" retry until max_retries.
383
+ out_path: Path | None = None
384
+ if policy.output_path is not None:
385
+ out_path = policy.output_path(params)
386
+ if out_path is not None:
387
+ param_env["OUTPUT_PATH"] = str(out_path)
388
+
389
+ if policy.predicate is not None:
390
+ predicate: Optional[Callable[[str], bool]] = policy.predicate
391
+ elif out_path is not None:
392
+ predicate = file_nonempty(out_path)
393
+ else:
394
+ predicate = None
395
+
396
+ skip = None
397
+ if policy.skip_when is not None:
398
+ captured_params = dict(params)
399
+ skip = lambda _ctx, _p=captured_params: policy.skip_when(_p) # noqa: E731
400
+
401
+ post = None
402
+ if policy.output_path is not None:
403
+ captured_params = dict(params)
404
+ def post(_out: str, ctx: Context, _p=captured_params) -> None:
405
+ op = policy.output_path(_p) if policy.output_path else None
406
+ if op is not None:
407
+ ctx.record_output(op)
408
+ return None
409
+
410
+ label = f"{recipe}[{params.get('slug') or 'branch'}]" \
411
+ if params.get("slug") else None
412
+
413
+ return Phase(
414
+ name=f"branch:{recipe}",
415
+ recipe_path=str(recipe_path),
416
+ build_env=lambda _ctx, _e=param_env: dict(_e),
417
+ success_predicate=predicate,
418
+ post_process=post,
419
+ skip_if=skip,
420
+ label=label,
421
+ )
422
+
423
+ # ------------------------------------------------------------------
424
+ # body
425
+
426
+ def _drain_body(self, queue: deque[Phase], ctx: Context) -> tuple[int, int, int]:
427
+ """Drain the body queue. Children spawned via post_process land at HEAD.
428
+
429
+ Bumps self._planned when post_process spawns new phases so the
430
+ `[step/planned]` banner stays accurate mid-run.
431
+ """
432
+ calls = 0
433
+ ran = 0
434
+ skipped = 0
435
+ executed = 0
436
+
437
+ while queue:
438
+ if executed >= self.config.max_queue_depth:
439
+ print(colored(
440
+ f"\nQueue depth cap ({self.config.max_queue_depth}) hit; "
441
+ f"aborting remaining phases.",
442
+ Color.RED,
443
+ ), file=sys.stderr)
444
+ break
445
+
446
+ phase = queue.popleft()
447
+ executed += 1
448
+ phase_calls, ok, children = self._run_phase_with_children(phase, ctx)
449
+ calls += phase_calls
450
+ if ok:
451
+ ran += 1
452
+ else:
453
+ skipped += 1
454
+ if children:
455
+ self._planned += len(children)
456
+ queue.extendleft(reversed(children))
457
+
458
+ return calls, ran, skipped
459
+
460
+ def _run_phase_with_children(self, phase: Phase, ctx: Context) -> tuple[int, bool, list[Phase]]:
461
+ self._announce(phase.name)
462
+ # skip_if check (after the banner so the operator sees which phase
463
+ # is being skipped and why).
464
+ skip_result = phase.skip_if(ctx) if phase.skip_if is not None else None
465
+ if skip_result:
466
+ msg = (f"Skipped phase {phase.name}: {skip_result}"
467
+ if isinstance(skip_result, str)
468
+ else f"Skipped phase {phase.name} (skip_if returned True)")
469
+ print(colored(msg, Color.YELLOW))
470
+ if ctx.session_dir:
471
+ log_step(ctx.session_dir, msg)
472
+ return 0, True, [] # skip counts as ok (handled by caller as skipped)
473
+
474
+ output = self._invoke_recipe(phase, ctx)
475
+ if output is None:
476
+ return 0, False, []
477
+
478
+ children: list[Phase] = []
479
+ if phase.post_process is not None:
480
+ try:
481
+ returned = phase.post_process(output, ctx)
482
+ if returned:
483
+ children = list(returned)
484
+ except Exception as e:
485
+ print(colored(f"\nPhase {phase.name} post_process raised: {e}", Color.RED),
486
+ file=sys.stderr)
487
+ if ctx.session_dir:
488
+ log_step(ctx.session_dir, f"Phase {phase.name} post_process raised: {e}")
489
+
490
+ if ctx.session_dir:
491
+ log_step(ctx.session_dir, f"Phase {phase.name} completed.")
492
+ return 1, True, children
493
+
494
+ # ------------------------------------------------------------------
495
+ # generic phase + recipe invocation
496
+
497
+ def _run_phase(self, phase: Phase, ctx: Context, *,
498
+ overlays: list[Path] | None = None,
499
+ is_summary: bool = False) -> tuple[int, bool]:
500
+ self._announce(phase.name)
501
+ output = self._invoke_recipe(phase, ctx, overlays=overlays)
502
+ if output is None:
503
+ return 0, False
504
+ if phase.post_process is not None:
505
+ try:
506
+ phase.post_process(output, ctx)
507
+ except Exception as e:
508
+ print(colored(f"\nPhase {phase.name} post_process raised: {e}", Color.RED),
509
+ file=sys.stderr)
510
+ if ctx.session_dir:
511
+ log_step(ctx.session_dir, f"Phase {phase.name} completed.")
512
+ return 1, True
513
+
514
+ def _invoke_recipe(self, phase: Phase, ctx: Context, *,
515
+ overlays: list[Path] | None = None) -> str | None:
516
+ phase_env = phase.build_env(ctx) if phase.build_env else {}
517
+ extra_env = {**ctx.base_env, **phase_env}
518
+ local_path = _local_overlay_for(Path(phase.recipe_path))
519
+ try:
520
+ return run_goose_with_retry(
521
+ phase.recipe_path,
522
+ self.model,
523
+ extra_env=extra_env,
524
+ max_retries=self.config.retry.max_retries,
525
+ base_delay=self.config.retry.base_delay,
526
+ success_predicate=phase.success_predicate,
527
+ label=phase.label,
528
+ environment=self.environment,
529
+ local_path=local_path,
530
+ overlay_paths=overlays or [],
531
+ )
532
+ except RuntimeError as e:
533
+ print(colored(f"\nPhase {phase.name} failed: {e}", Color.RED), file=sys.stderr)
534
+ if ctx.session_dir:
535
+ log_step(ctx.session_dir, f"Phase {phase.name} FAILED: {e}")
536
+ return None
537
+
538
+ # ------------------------------------------------------------------
539
+
540
+ def _resolve_recipe_path(self, recipe: str) -> str:
541
+ """Look up a body recipe by name in the engine's recipes directory.
542
+
543
+ Accepts a bare name ("to-outreach") or a full path
544
+ ("recipes/to-outreach.yaml"). Bare names resolve against
545
+ engine.recipes_dir() with .yaml suffix.
546
+ """
547
+ if recipe.endswith(".yaml") or recipe.endswith(".yml") or "/" in recipe:
548
+ return recipe
549
+ return f"{self.engine.recipes_dir()}/{recipe}.yaml"
550
+
551
+
552
+ def _review_output_parseable(output: str) -> bool:
553
+ """Default review success_predicate: at least extract_json must succeed.
554
+
555
+ Catches mid-stream truncation (provider decode error after partial
556
+ output) and other "looks fine to goose but doesn't parse" failures.
557
+ Validation of required keys + status enum + protocol version happens
558
+ in validate_review after extraction; this predicate's only job is
559
+ "is there enough output to try."
560
+ """
561
+ return extract_json_with_provenance(output) is not None
562
+
563
+
564
+ def _params_to_env(params: dict[str, Any]) -> dict[str, str]:
565
+ """Convert routing params to env vars. Keys uppercased; values stringified."""
566
+ out: dict[str, str] = {}
567
+ for k, v in params.items():
568
+ if v is None:
569
+ continue
570
+ out[str(k).upper()] = str(v)
571
+ return out
572
+
573
+
574
+ def _local_overlay_for(recipe_path: Path) -> Path | None:
575
+ """Compute the conventional .local.yaml sibling for an overlay base."""
576
+ if recipe_path.suffix not in (".yaml", ".yml"):
577
+ return None
578
+ candidate = recipe_path.with_suffix("").with_name(
579
+ recipe_path.stem + ".local"
580
+ ).with_suffix(recipe_path.suffix)
581
+ return candidate if candidate.exists() else None