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/__init__.py +42 -0
- gooseloop/__main__.py +181 -0
- gooseloop/artifact.py +82 -0
- gooseloop/branch_policy.py +49 -0
- gooseloop/config.py +104 -0
- gooseloop/context_prepend.py +316 -0
- gooseloop/contrib/__init__.py +18 -0
- gooseloop/contrib/claude_handoff.py +74 -0
- gooseloop/contrib/customer_pipeline.py +98 -0
- gooseloop/engine.py +68 -0
- gooseloop/environment.py +31 -0
- gooseloop/extract.py +200 -0
- gooseloop/footer.py +75 -0
- gooseloop/goose.py +340 -0
- gooseloop/looper.py +581 -0
- gooseloop/phase.py +146 -0
- gooseloop/predicates.py +69 -0
- gooseloop/protocol.py +197 -0
- gooseloop/recipe_merge.py +152 -0
- gooseloop/session.py +41 -0
- gooseloop/text.py +60 -0
- gooseloop/toolkit.py +243 -0
- gooseloop-0.1.0.dist-info/METADATA +169 -0
- gooseloop-0.1.0.dist-info/RECORD +28 -0
- gooseloop-0.1.0.dist-info/WHEEL +5 -0
- gooseloop-0.1.0.dist-info/entry_points.txt +2 -0
- gooseloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- gooseloop-0.1.0.dist-info/top_level.txt +1 -0
gooseloop/extract.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Extract a JSON deliverable from LLM output.
|
|
2
|
+
|
|
3
|
+
LLMs are non-deterministic emitters. A recipe that says "wrap the payload
|
|
4
|
+
in <<<DELIVERABLE_JSON>>> ... <<<END_DELIVERABLE>>>" will, against a
|
|
5
|
+
weaker or just-creatively-paraphrasing model, get back ``<<<DELIMITED_JSON>>>``
|
|
6
|
+
or a markdown ```json fence`` or any other "obviously a wrapped JSON
|
|
7
|
+
deliverable" shape. The strict canonical-only parser throws away
|
|
8
|
+
parseable responses for vocabulary mismatches; the legacy "any balanced
|
|
9
|
+
object anywhere" fallback silently surfaces intermediate scratch dicts.
|
|
10
|
+
|
|
11
|
+
Both are wrong. The right shape is a small ordered list of wrapper
|
|
12
|
+
recognizers. Each one answers: "is there a JSON payload wrapped by
|
|
13
|
+
*something* that looks like a deliverable marker?" Tried in order from
|
|
14
|
+
most-specific to most-permissive; the first one that yields a parseable
|
|
15
|
+
balanced object wins. Bare JSON in prose still fails — every recognizer
|
|
16
|
+
requires *some* wrapper, so the "scattered intermediate dict" failure
|
|
17
|
+
mode the legacy fallback enabled stays dead.
|
|
18
|
+
|
|
19
|
+
Provenance is preserved on the way out. The looper logs (and surfaces
|
|
20
|
+
as an operator action) when a non-canonical recognizer matched, so the
|
|
21
|
+
operator can tighten the recipe at their own pace without the framework
|
|
22
|
+
silently absorbing drift.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Callable, Optional
|
|
31
|
+
|
|
32
|
+
from .text import strip_ansi
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Canonical wrapper. Recipes the framework ships use this verbatim;
|
|
36
|
+
# other recognizers exist for tolerance, not to encourage drift.
|
|
37
|
+
DELIVERABLE_START = "<<<DELIVERABLE_JSON>>>"
|
|
38
|
+
DELIVERABLE_END = "<<<END_DELIVERABLE>>>"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Extracted:
|
|
43
|
+
"""A parsed deliverable plus which recognizer matched."""
|
|
44
|
+
payload: dict
|
|
45
|
+
recognizer: str # "canonical" | "angle_sentinel" | "markdown_fence"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_canonical(self) -> bool:
|
|
49
|
+
return self.recognizer == "canonical"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- recognizers ---------------------------------------------------
|
|
53
|
+
#
|
|
54
|
+
# Each recognizer takes ANSI-stripped text and returns the candidate
|
|
55
|
+
# payload string (the bit between wrapper markers) or None if its
|
|
56
|
+
# wrapper shape isn't present. extract_json then runs the balanced-
|
|
57
|
+
# object scan over the candidate; an unparseable candidate falls
|
|
58
|
+
# through to the next recognizer.
|
|
59
|
+
|
|
60
|
+
WrapperRecognizer = Callable[[str], Optional[str]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _canonical_sentinel(text: str) -> Optional[str]:
|
|
64
|
+
"""Exact <<<DELIVERABLE_JSON>>> ... <<<END_DELIVERABLE>>>. Last
|
|
65
|
+
occurrence wins (models often echo the spec earlier in their prose,
|
|
66
|
+
then emit the real deliverable at the bottom)."""
|
|
67
|
+
start = text.rfind(DELIVERABLE_START)
|
|
68
|
+
if start == -1:
|
|
69
|
+
return None
|
|
70
|
+
payload_start = start + len(DELIVERABLE_START)
|
|
71
|
+
end = text.find(DELIVERABLE_END, payload_start)
|
|
72
|
+
return text[payload_start:end] if end != -1 else text[payload_start:]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_ANGLE_TAG_RE = re.compile(r"<<<\s*([A-Z][A-Z0-9_]*)\s*>>>")
|
|
76
|
+
_PAYLOAD_WORDS = ("JSON", "DELIVERABLE", "PAYLOAD", "OUTPUT", "RESULT")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _angle_sentinel(text: str) -> Optional[str]:
|
|
80
|
+
"""Generic <<<TAG>>> wrapper where TAG looks like a payload marker.
|
|
81
|
+
|
|
82
|
+
Catches DELIMITED_JSON, JSON_BEGIN, DELIVERABLE_PAYLOAD, OUTPUT_JSON,
|
|
83
|
+
and anything else triple-angle-wrapped with a JSON/DELIVERABLE/PAYLOAD/
|
|
84
|
+
OUTPUT/RESULT word in the tag. The last matching opener wins. We
|
|
85
|
+
don't try to match a specific close tag — the balanced-object scan
|
|
86
|
+
in extract_json handles trailing close markers as ignorable suffix.
|
|
87
|
+
"""
|
|
88
|
+
matches = list(_ANGLE_TAG_RE.finditer(text))
|
|
89
|
+
if not matches:
|
|
90
|
+
return None
|
|
91
|
+
openers = [
|
|
92
|
+
m for m in matches
|
|
93
|
+
if "END" not in m.group(1).split("_")
|
|
94
|
+
and any(w in m.group(1) for w in _PAYLOAD_WORDS)
|
|
95
|
+
]
|
|
96
|
+
if not openers:
|
|
97
|
+
return None
|
|
98
|
+
return text[openers[-1].end():]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_FENCE_RE = re.compile(r"```(?:json)?\s*\n?(.*?)\n?```", re.DOTALL | re.IGNORECASE)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _markdown_fence(text: str) -> Optional[str]:
|
|
105
|
+
"""Markdown code fence: ```json ... ``` or bare ``` ... ```. Last fence wins.
|
|
106
|
+
|
|
107
|
+
The balanced-object scan filters out fences that contain code rather
|
|
108
|
+
than JSON, so we don't need to discriminate on the language tag.
|
|
109
|
+
"""
|
|
110
|
+
matches = list(_FENCE_RE.finditer(text))
|
|
111
|
+
if not matches:
|
|
112
|
+
return None
|
|
113
|
+
return matches[-1].group(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Tolerant recognizers — tried only when no canonical opener is present.
|
|
117
|
+
# Canonical match is a commitment from the model (it claimed to follow
|
|
118
|
+
# the contract); we honour the commitment by parsing-or-refusing the
|
|
119
|
+
# canonical payload rather than fishing for JSON elsewhere when the
|
|
120
|
+
# canonical payload turns out to be garbage.
|
|
121
|
+
_TOLERANT_RECOGNIZERS: list[tuple[str, WrapperRecognizer]] = [
|
|
122
|
+
("angle_sentinel", _angle_sentinel),
|
|
123
|
+
("markdown_fence", _markdown_fence),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- public API ----------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def extract_json_with_provenance(text: str) -> Optional[Extracted]:
|
|
130
|
+
"""Parse the deliverable. Returns Extracted on success, None on refusal.
|
|
131
|
+
|
|
132
|
+
Two-stage dispatch:
|
|
133
|
+
1. Canonical opener present → parse the canonical payload or refuse.
|
|
134
|
+
No fallthrough — the model committed to the canonical contract.
|
|
135
|
+
2. No canonical opener → try tolerant recognizers in order;
|
|
136
|
+
the first one yielding a parseable dict wins.
|
|
137
|
+
"""
|
|
138
|
+
clean = strip_ansi(text)
|
|
139
|
+
|
|
140
|
+
if DELIVERABLE_START in clean:
|
|
141
|
+
payload = _try_parse(_canonical_sentinel(clean))
|
|
142
|
+
return Extracted(payload=payload, recognizer="canonical") if payload else None
|
|
143
|
+
|
|
144
|
+
for name, recognizer in _TOLERANT_RECOGNIZERS:
|
|
145
|
+
payload = _try_parse(recognizer(clean))
|
|
146
|
+
if payload is not None:
|
|
147
|
+
return Extracted(payload=payload, recognizer=name)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _try_parse(candidate: Optional[str]) -> Optional[dict]:
|
|
152
|
+
if candidate is None:
|
|
153
|
+
return None
|
|
154
|
+
balanced = _first_balanced_object(candidate)
|
|
155
|
+
if balanced is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
payload = json.loads(balanced)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
return None
|
|
161
|
+
return payload if isinstance(payload, dict) else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_json(text: str) -> Optional[dict]:
|
|
165
|
+
"""Backwards-compatible thin wrapper for callers that don't need provenance."""
|
|
166
|
+
result = extract_json_with_provenance(text)
|
|
167
|
+
return result.payload if result else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- balanced-object scan -----------------------------------------
|
|
171
|
+
|
|
172
|
+
def _first_balanced_object(text: str) -> Optional[str]:
|
|
173
|
+
"""Return the first balanced {...} substring, respecting string literals.
|
|
174
|
+
|
|
175
|
+
Lives here, not in text.py, because its only consumer is the JSON
|
|
176
|
+
extractor. Pure: depends on nothing else in the package.
|
|
177
|
+
"""
|
|
178
|
+
start = text.find("{")
|
|
179
|
+
if start == -1:
|
|
180
|
+
return None
|
|
181
|
+
brace_count = 0
|
|
182
|
+
in_string = False
|
|
183
|
+
escape = False
|
|
184
|
+
for j, ch in enumerate(text[start:], start=start):
|
|
185
|
+
if escape:
|
|
186
|
+
escape = False
|
|
187
|
+
continue
|
|
188
|
+
if ch == "\\":
|
|
189
|
+
escape = True
|
|
190
|
+
continue
|
|
191
|
+
if ch == '"' and not escape:
|
|
192
|
+
in_string = not in_string
|
|
193
|
+
if not in_string:
|
|
194
|
+
if ch == "{":
|
|
195
|
+
brace_count += 1
|
|
196
|
+
elif ch == "}":
|
|
197
|
+
brace_count -= 1
|
|
198
|
+
if brace_count == 0:
|
|
199
|
+
return text[start:j + 1]
|
|
200
|
+
return None
|
gooseloop/footer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Footers: per-call summary line and per-session multi-line wrap-up."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .text import Color, colored
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _fmt_duration(seconds: float) -> str:
|
|
9
|
+
"""e.g. 47s, 1m 23s, 12m 5s."""
|
|
10
|
+
s = int(round(seconds))
|
|
11
|
+
if s < 60:
|
|
12
|
+
return f"{s}s"
|
|
13
|
+
return f"{s // 60}m {s % 60}s"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_call_footer(label: str, elapsed: float, shell_calls: int,
|
|
17
|
+
retries: int, status: str = "ok") -> None:
|
|
18
|
+
"""One-line footer after a single goose call."""
|
|
19
|
+
parts = [
|
|
20
|
+
label,
|
|
21
|
+
_fmt_duration(elapsed),
|
|
22
|
+
f"{shell_calls} shell",
|
|
23
|
+
]
|
|
24
|
+
if retries:
|
|
25
|
+
parts.append(f"{retries} retr{'y' if retries == 1 else 'ies'}")
|
|
26
|
+
parts.append(status)
|
|
27
|
+
line = " ─ " + " · ".join(parts) + " ─"
|
|
28
|
+
|
|
29
|
+
color = Color.GREEN if status in ("ok", "skipped") else Color.RED
|
|
30
|
+
print(colored(line, color))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def print_session_footer(elapsed: float,
|
|
34
|
+
goose_calls: int,
|
|
35
|
+
actions_planned: int,
|
|
36
|
+
actions_ran: int,
|
|
37
|
+
actions_skipped: int,
|
|
38
|
+
outputs: list[str],
|
|
39
|
+
operator_actions: list[dict] | None = None,
|
|
40
|
+
session_dir: Path | None = None) -> None:
|
|
41
|
+
"""Multi-line summary at end of a begin_loop() pass."""
|
|
42
|
+
title = f"═══ session complete · {_fmt_duration(elapsed)} ═══"
|
|
43
|
+
print()
|
|
44
|
+
print(colored(title, Color.CYAN))
|
|
45
|
+
|
|
46
|
+
rows: list[tuple[str, str]] = [
|
|
47
|
+
("goose calls", str(goose_calls)),
|
|
48
|
+
("actions", f"{actions_planned} planned · {actions_ran} ran · {actions_skipped} skipped"),
|
|
49
|
+
]
|
|
50
|
+
label_width = max(len(k) for k, _ in rows)
|
|
51
|
+
for k, v in rows:
|
|
52
|
+
print(colored(f" {k:<{label_width}} {v}", Color.CYAN))
|
|
53
|
+
|
|
54
|
+
if outputs or session_dir:
|
|
55
|
+
print(colored(f" {'outputs':<{label_width}}", Color.CYAN))
|
|
56
|
+
if session_dir:
|
|
57
|
+
print(colored(f" {' ' * label_width} {session_dir}/", Color.CYAN))
|
|
58
|
+
for out in outputs:
|
|
59
|
+
print(colored(f" {' ' * label_width} {out}", Color.CYAN))
|
|
60
|
+
|
|
61
|
+
if operator_actions:
|
|
62
|
+
print(colored(f" {'operator':<{label_width}}", Color.YELLOW))
|
|
63
|
+
for entry in operator_actions:
|
|
64
|
+
action = entry.get("action", "(no action)")
|
|
65
|
+
why = entry.get("why", "")
|
|
66
|
+
print(colored(f" {' ' * label_width} - {action}", Color.YELLOW))
|
|
67
|
+
if why:
|
|
68
|
+
print(colored(f" {' ' * label_width} why: {why}", Color.YELLOW))
|
|
69
|
+
print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def recipe_label(recipe_path: str, suffix: str | None = None) -> str:
|
|
73
|
+
"""Compact label for a recipe path. 'recipes/to-outreach.yaml' -> 'to-outreach'."""
|
|
74
|
+
name = Path(recipe_path).stem
|
|
75
|
+
return f"{name}[{suffix}]" if suffix else name
|
gooseloop/goose.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Goose CLI invocation: subprocess wrapper with retry and rate-limit handling.
|
|
2
|
+
|
|
3
|
+
Pure execution layer. Knows about goose, OpenRouter-style rate-limit messages,
|
|
4
|
+
and transient errors. Knows nothing about Phases, engines, or sessions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .context_prepend import _NO_FOLD_WIDTH, _BlockStyleDumper, render_recipe_with_context
|
|
19
|
+
from .footer import print_call_footer, recipe_label
|
|
20
|
+
from .recipe_merge import load_layered_recipe
|
|
21
|
+
from .text import Color, colored
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Per-minute rate-limit windows reset every 60s. 65s gives a 5s buffer so the
|
|
25
|
+
# next request lands clearly inside the new window.
|
|
26
|
+
RATE_LIMIT_WAIT_SECONDS = 65
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RATE_LIMIT_LINE_RE = re.compile(
|
|
30
|
+
r"^[ \t]*(rate limit|429\b|too many requests)",
|
|
31
|
+
re.MULTILINE | re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_TRANSIENT_ERROR_LINE_RE = re.compile(
|
|
35
|
+
# Line-start anchored. Keeps the model's mid-prose narration
|
|
36
|
+
# ("I'll handle server errors here") from tripping the regex.
|
|
37
|
+
r"^[ \t]*("
|
|
38
|
+
r"server error\b"
|
|
39
|
+
r"|provider returned error\b"
|
|
40
|
+
r"|5\d{2}\s+(internal|service|gateway|bad|server|error)"
|
|
41
|
+
r"|error[: ]+5\d{2}\b"
|
|
42
|
+
r"|connection (refused|reset|timed? out)"
|
|
43
|
+
r")",
|
|
44
|
+
re.MULTILINE | re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Stream-level decode failures: goose's HTTP client choked mid-response
|
|
48
|
+
# (network blip, provider truncation, partial chunked stream). Real
|
|
49
|
+
# observed wording from 2026-06-04: "Ran into this error: Request
|
|
50
|
+
# failed: Stream decode error: error decoding response body." The
|
|
51
|
+
# prefix "Ran into this error:" means line-start anchoring won't catch
|
|
52
|
+
# it — these patterns are specific enough that mid-line search is safe
|
|
53
|
+
# (a model isn't going to monologue "stream decode error" by accident).
|
|
54
|
+
_STREAM_DECODE_RE = re.compile(
|
|
55
|
+
r"("
|
|
56
|
+
r"stream decode error"
|
|
57
|
+
r"|error decoding response body"
|
|
58
|
+
r"|request failed:.*decode"
|
|
59
|
+
r")",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Persistent failure modes — retrying won't help. Match these before
|
|
64
|
+
# the transient check so we fail-fast instead of burning max_retries
|
|
65
|
+
# waiting for a provider/model combination that fundamentally can't
|
|
66
|
+
# complete the task.
|
|
67
|
+
#
|
|
68
|
+
# - "filtered for safety": openrouter's safety filter is stripping
|
|
69
|
+
# tool-call payloads (seen with owl-alpha/LongCat).
|
|
70
|
+
# - "tool response was empty": goose received nothing parseable back.
|
|
71
|
+
# - "tool output is being processed by the underlying provider library":
|
|
72
|
+
# provider-side post-processing swallowed the response.
|
|
73
|
+
# - "Invalid recipe": goose couldn't parse the recipe at all (e.g. a
|
|
74
|
+
# MiniJinja template syntax error). The recipe bytes are fixed for the
|
|
75
|
+
# run, so every retry re-parses the same file and fails identically —
|
|
76
|
+
# retrying is pure waste, and the operator needs to see the error now,
|
|
77
|
+
# not after 6 backoffs. (2026-06-05: a context-injected recap broke the
|
|
78
|
+
# summary recipe's template and burned the full retry budget.)
|
|
79
|
+
_PERSISTENT_FAILURE_RE = re.compile(
|
|
80
|
+
r"("
|
|
81
|
+
r"filtered for safety"
|
|
82
|
+
r"|tool response was empty"
|
|
83
|
+
r"|tool output is being processed by the underlying provider library"
|
|
84
|
+
r"|Invalid recipe"
|
|
85
|
+
r")",
|
|
86
|
+
re.IGNORECASE,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_rate_limit(output: str) -> bool:
|
|
91
|
+
return bool(_RATE_LIMIT_LINE_RE.search(output))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_persistent_failure(output: str) -> bool:
|
|
95
|
+
"""Failures that won't get better with retry. Caller should fail fast."""
|
|
96
|
+
return bool(_PERSISTENT_FAILURE_RE.search(output))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_recipe_error(output: str) -> bool:
|
|
100
|
+
"""A recipe-parse failure specifically (vs a provider/model failure).
|
|
101
|
+
|
|
102
|
+
Deterministic in the recipe bytes, so it's both persistent AND the
|
|
103
|
+
operator's to fix — worth a distinct, actionable message."""
|
|
104
|
+
return "invalid recipe" in output.lower()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _first_recipe_error_line(output: str) -> str | None:
|
|
108
|
+
"""The goose line carrying the recipe-parse error, for the fail-fast
|
|
109
|
+
message — so the operator sees `Invalid recipe: syntax error: ...`
|
|
110
|
+
instead of having to scroll back through the streamed output."""
|
|
111
|
+
for line in output.splitlines():
|
|
112
|
+
if "invalid recipe" in line.lower():
|
|
113
|
+
return line.strip()
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_transient_error(output: str, returncode: int) -> bool:
|
|
118
|
+
if returncode != 0:
|
|
119
|
+
return True
|
|
120
|
+
if _TRANSIENT_ERROR_LINE_RE.search(output):
|
|
121
|
+
return True
|
|
122
|
+
if _STREAM_DECODE_RE.search(output):
|
|
123
|
+
return True
|
|
124
|
+
if _is_rate_limit(output):
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _countdown_sleep(seconds: int, header: str, color: str | None = None) -> None:
|
|
130
|
+
color = color or Color.YELLOW
|
|
131
|
+
if not sys.stdout.isatty():
|
|
132
|
+
print(colored(f" {header} — waiting {seconds}s", color))
|
|
133
|
+
time.sleep(seconds)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
width = max(50, len(header) + 6)
|
|
137
|
+
top = "┌" + "─" * (width - 2) + "┐"
|
|
138
|
+
middle = "│ " + header.ljust(width - 4) + "│"
|
|
139
|
+
bottom = "└" + "─" * (width - 2) + "┘"
|
|
140
|
+
print(colored(top, color))
|
|
141
|
+
print(colored(middle, color))
|
|
142
|
+
print(colored(bottom, color))
|
|
143
|
+
|
|
144
|
+
bar_width = 30
|
|
145
|
+
line_pad = bar_width + 40
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
for elapsed in range(seconds):
|
|
149
|
+
remaining = seconds - elapsed
|
|
150
|
+
filled = int(elapsed / seconds * bar_width)
|
|
151
|
+
bar = "▰" * filled + "▱" * (bar_width - filled)
|
|
152
|
+
line = f" retrying in {remaining:>3}s {bar}"
|
|
153
|
+
sys.stdout.write(f"\r{colored(line, color)}")
|
|
154
|
+
sys.stdout.flush()
|
|
155
|
+
time.sleep(1)
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
sys.stdout.write("\r" + " " * line_pad + "\r")
|
|
158
|
+
sys.stdout.flush()
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
sys.stdout.write("\r" + " " * line_pad + "\r")
|
|
162
|
+
sys.stdout.flush()
|
|
163
|
+
print(colored(" retrying now...", Color.GREEN))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_goose_internal(recipe_path: str, model: str,
|
|
167
|
+
extra_env: dict | None = None) -> tuple[str, int]:
|
|
168
|
+
env = os.environ.copy()
|
|
169
|
+
if extra_env:
|
|
170
|
+
env.update(extra_env)
|
|
171
|
+
|
|
172
|
+
cmd = ["goose", "run", "--recipe", recipe_path, "--model", model]
|
|
173
|
+
proc = subprocess.Popen(
|
|
174
|
+
cmd,
|
|
175
|
+
stdout=subprocess.PIPE,
|
|
176
|
+
stderr=subprocess.STDOUT,
|
|
177
|
+
text=True,
|
|
178
|
+
env=env,
|
|
179
|
+
bufsize=1,
|
|
180
|
+
)
|
|
181
|
+
lines = []
|
|
182
|
+
try:
|
|
183
|
+
assert proc.stdout is not None
|
|
184
|
+
for line in proc.stdout:
|
|
185
|
+
sys.stdout.write(line)
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
lines.append(line)
|
|
188
|
+
except KeyboardInterrupt:
|
|
189
|
+
proc.terminate()
|
|
190
|
+
raise
|
|
191
|
+
proc.wait()
|
|
192
|
+
return "".join(lines), proc.returncode
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def count_shell_calls(output: str) -> int:
|
|
196
|
+
"""Goose marks each shell tool call with '▸ shell' in its streamed output."""
|
|
197
|
+
return output.count("▸ shell")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@contextlib.contextmanager
|
|
201
|
+
def _prepared_recipe(recipe_path: Path,
|
|
202
|
+
extra_env: dict | None,
|
|
203
|
+
*,
|
|
204
|
+
environment: Any = None,
|
|
205
|
+
local_path: Path | None = None,
|
|
206
|
+
overlay_paths: list[Path] | None = None):
|
|
207
|
+
"""Yield the effective recipe path: overlay-merged + context-block rendered.
|
|
208
|
+
|
|
209
|
+
Steps:
|
|
210
|
+
1. Merge base + local + CLI overlays into one dict (recipe_merge).
|
|
211
|
+
2. Resolve the context: block to literal text (context_prepend).
|
|
212
|
+
3. Write a temp YAML file goose can read; yield its path.
|
|
213
|
+
|
|
214
|
+
Cleanup happens on context exit unless GOOSER_KEEP_RENDERED=1 is set.
|
|
215
|
+
"""
|
|
216
|
+
merged = load_layered_recipe(
|
|
217
|
+
recipe_path,
|
|
218
|
+
local_path=local_path,
|
|
219
|
+
overlay_paths=overlay_paths,
|
|
220
|
+
)
|
|
221
|
+
rendered = render_recipe_with_context(merged, extra_env or {}, environment=environment)
|
|
222
|
+
if rendered is None:
|
|
223
|
+
# No context: block — write the merged dict to a temp file so any
|
|
224
|
+
# overlay changes still reach goose.
|
|
225
|
+
import tempfile
|
|
226
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
227
|
+
mode="w", suffix=".merged.yaml", delete=False,
|
|
228
|
+
)
|
|
229
|
+
yaml.dump(merged, tmp, Dumper=_BlockStyleDumper, sort_keys=False,
|
|
230
|
+
default_flow_style=False, allow_unicode=True, width=_NO_FOLD_WIDTH)
|
|
231
|
+
tmp.close()
|
|
232
|
+
rendered = tmp.name
|
|
233
|
+
try:
|
|
234
|
+
yield rendered
|
|
235
|
+
finally:
|
|
236
|
+
if not os.environ.get("GOOSER_KEEP_RENDERED"):
|
|
237
|
+
try:
|
|
238
|
+
os.unlink(rendered)
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def run_goose_with_retry(
|
|
244
|
+
recipe_path: str,
|
|
245
|
+
model: str,
|
|
246
|
+
extra_env: dict | None = None,
|
|
247
|
+
*,
|
|
248
|
+
max_retries: int = 6,
|
|
249
|
+
base_delay: int = 5,
|
|
250
|
+
success_predicate: Optional[Callable[[str], bool]] = None,
|
|
251
|
+
label: str | None = None,
|
|
252
|
+
environment: Any = None,
|
|
253
|
+
local_path: Path | None = None,
|
|
254
|
+
overlay_paths: list[Path] | None = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Run goose with automatic retry on transient errors.
|
|
257
|
+
|
|
258
|
+
Rate-limit errors wait RATE_LIMIT_WAIT_SECONDS (65s). Other transient
|
|
259
|
+
errors use base_delay * (attempt+1) backoff. `success_predicate(output)`
|
|
260
|
+
lets stdout-deliverable recipes keep usable output when a trailing
|
|
261
|
+
rate-limit message follows the real result.
|
|
262
|
+
|
|
263
|
+
Raises RuntimeError if all retries exhausted.
|
|
264
|
+
"""
|
|
265
|
+
start = time.perf_counter()
|
|
266
|
+
retries_used = 0
|
|
267
|
+
final_output: str | None = None
|
|
268
|
+
|
|
269
|
+
with _prepared_recipe(
|
|
270
|
+
Path(recipe_path), extra_env,
|
|
271
|
+
environment=environment,
|
|
272
|
+
local_path=local_path,
|
|
273
|
+
overlay_paths=overlay_paths,
|
|
274
|
+
) as effective_path:
|
|
275
|
+
for attempt in range(max_retries):
|
|
276
|
+
output, returncode = _run_goose_internal(effective_path, model, extra_env)
|
|
277
|
+
|
|
278
|
+
# Persistent failure shortcuts the retry loop: a provider that
|
|
279
|
+
# filters tool calls or a model that can't speak goose's tool
|
|
280
|
+
# protocol will not improve with another attempt. Fail fast
|
|
281
|
+
# rather than burn max_retries on something structurally broken.
|
|
282
|
+
if _is_persistent_failure(output):
|
|
283
|
+
if _is_recipe_error(output):
|
|
284
|
+
reason = (
|
|
285
|
+
"Recipe failed to parse; not retrying (the recipe bytes "
|
|
286
|
+
"are identical every attempt). Fix the recipe/template:"
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
reason = (
|
|
290
|
+
"Persistent provider/model failure detected; not retrying "
|
|
291
|
+
"(recipe + model + provider combination appears incompatible)."
|
|
292
|
+
)
|
|
293
|
+
print(colored(reason, Color.RED), file=sys.stderr)
|
|
294
|
+
detail = _first_recipe_error_line(output)
|
|
295
|
+
if detail:
|
|
296
|
+
print(colored(f" {detail}", Color.RED), file=sys.stderr)
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if success_predicate is not None:
|
|
300
|
+
success = success_predicate(output)
|
|
301
|
+
else:
|
|
302
|
+
success = not _is_transient_error(output, returncode)
|
|
303
|
+
|
|
304
|
+
if success:
|
|
305
|
+
final_output = output
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
retries_used += 1
|
|
309
|
+
if _is_rate_limit(output):
|
|
310
|
+
delay = RATE_LIMIT_WAIT_SECONDS
|
|
311
|
+
header = f"Rate limit hit · attempt {attempt + 1}/{max_retries}"
|
|
312
|
+
color = Color.YELLOW
|
|
313
|
+
else:
|
|
314
|
+
delay = base_delay * (attempt + 1)
|
|
315
|
+
header = f"Transient error · attempt {attempt + 1}/{max_retries}"
|
|
316
|
+
color = Color.MAGENTA
|
|
317
|
+
_countdown_sleep(delay, header, color=color)
|
|
318
|
+
|
|
319
|
+
if final_output is None:
|
|
320
|
+
elapsed = time.perf_counter() - start
|
|
321
|
+
print_call_footer(
|
|
322
|
+
label or recipe_label(recipe_path),
|
|
323
|
+
elapsed=elapsed, shell_calls=0,
|
|
324
|
+
retries=retries_used, status="failed",
|
|
325
|
+
)
|
|
326
|
+
attempts_desc = (
|
|
327
|
+
"without retrying (persistent failure)" if retries_used == 0
|
|
328
|
+
else f"after {retries_used} retr{'y' if retries_used == 1 else 'ies'}"
|
|
329
|
+
)
|
|
330
|
+
raise RuntimeError(f"goose failed {attempts_desc}: {recipe_path}")
|
|
331
|
+
|
|
332
|
+
elapsed = time.perf_counter() - start
|
|
333
|
+
print_call_footer(
|
|
334
|
+
label or recipe_label(recipe_path),
|
|
335
|
+
elapsed=elapsed,
|
|
336
|
+
shell_calls=count_shell_calls(final_output),
|
|
337
|
+
retries=retries_used,
|
|
338
|
+
status="ok",
|
|
339
|
+
)
|
|
340
|
+
return final_output
|