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/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""gooseloop — an execution shell for goose-recipe pipelines.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
GooseLooper - the execution shell
|
|
6
|
+
Engine - abstract base for engines
|
|
7
|
+
Environment - abstract base for environments (just env_vars)
|
|
8
|
+
Phase - one recipe invocation in a Pipeline
|
|
9
|
+
Pipeline - named-slot dataclass: review + body + summary
|
|
10
|
+
Context - passed to phase callables; typed ledger methods
|
|
11
|
+
BranchPolicy - per-recipe rules for routing[] -> Phase building
|
|
12
|
+
LooperConfig - resolved gooseloop.toml as a value object
|
|
13
|
+
predicates - success_predicate factories
|
|
14
|
+
protocol - ReviewOutput / OperatorAction / RoutingEntry types
|
|
15
|
+
toolkit - stdlib-only engine helpers (Source, fetch_url, state io)
|
|
16
|
+
artifact - versioned artifact contracts for engine composition
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from . import artifact, predicates, protocol, toolkit
|
|
20
|
+
from .branch_policy import BranchPolicy
|
|
21
|
+
from .config import LooperConfig
|
|
22
|
+
from .engine import Engine
|
|
23
|
+
from .environment import Environment
|
|
24
|
+
from .looper import GooseLooper
|
|
25
|
+
from .phase import Context, Phase, Pipeline
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BranchPolicy",
|
|
29
|
+
"Context",
|
|
30
|
+
"Engine",
|
|
31
|
+
"Environment",
|
|
32
|
+
"GooseLooper",
|
|
33
|
+
"LooperConfig",
|
|
34
|
+
"Phase",
|
|
35
|
+
"Pipeline",
|
|
36
|
+
"artifact",
|
|
37
|
+
"predicates",
|
|
38
|
+
"protocol",
|
|
39
|
+
"toolkit",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
gooseloop/__main__.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""`gooseloop` CLI entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
run Run the configured engine, one pass.
|
|
5
|
+
--review-only Stop after the review phase.
|
|
6
|
+
--review-overlay PATH Stack a review-recipe overlay (repeatable).
|
|
7
|
+
--summary-overlay PATH Stack a summary-recipe overlay (repeatable).
|
|
8
|
+
--no-save Don't write a session folder.
|
|
9
|
+
--no-validate Skip engine.precheck().
|
|
10
|
+
--model NAME Override the configured model.
|
|
11
|
+
|
|
12
|
+
recipe --resolve NAME Print the fully-merged recipe NAME.
|
|
13
|
+
|
|
14
|
+
engines Print the configured engine module.
|
|
15
|
+
|
|
16
|
+
Engine discovery: gooseloop.toml's [gooseloop] engine_module = "..." is
|
|
17
|
+
imported and its module-level `engine` attribute is instantiated. Engines
|
|
18
|
+
expose their class as a module-level callable in their __init__.py.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import importlib
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .config import LooperConfig
|
|
29
|
+
from .engine import Engine
|
|
30
|
+
from .environment import Environment
|
|
31
|
+
from .looper import GooseLooper
|
|
32
|
+
from .recipe_merge import load_layered_recipe, resolved_recipe_yaml
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
parser = argparse.ArgumentParser(prog="gooseloop")
|
|
37
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
38
|
+
|
|
39
|
+
run = sub.add_parser("run", help="run the configured engine, one pass")
|
|
40
|
+
run.add_argument("-e", "--engine", default=None, metavar="MODULE",
|
|
41
|
+
help="override the engine module (default: from gooseloop.toml)")
|
|
42
|
+
run.add_argument("--review-only", action="store_true",
|
|
43
|
+
help="stop after the review phase")
|
|
44
|
+
run.add_argument("--review-overlay", action="append", default=[],
|
|
45
|
+
metavar="PATH", help="stack a review-recipe overlay")
|
|
46
|
+
run.add_argument("--summary-overlay", action="append", default=[],
|
|
47
|
+
metavar="PATH", help="stack a summary-recipe overlay")
|
|
48
|
+
run.add_argument("--no-save", action="store_true",
|
|
49
|
+
help="do not write a session folder")
|
|
50
|
+
run.add_argument("--no-validate", action="store_true",
|
|
51
|
+
help="skip engine.precheck()")
|
|
52
|
+
run.add_argument("--model", default=None, help="override the configured model")
|
|
53
|
+
|
|
54
|
+
rec = sub.add_parser("recipe", help="recipe utilities")
|
|
55
|
+
rec.add_argument("--resolve", metavar="NAME",
|
|
56
|
+
help="print the fully-merged recipe NAME")
|
|
57
|
+
rec.add_argument("--overlay", action="append", default=[], metavar="PATH",
|
|
58
|
+
help="extra overlay path to include in the merge")
|
|
59
|
+
|
|
60
|
+
sub.add_parser("engines", help="show the configured engine module")
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args(argv)
|
|
63
|
+
|
|
64
|
+
if args.cmd == "run":
|
|
65
|
+
return _cmd_run(args)
|
|
66
|
+
if args.cmd == "recipe":
|
|
67
|
+
return _cmd_recipe(args)
|
|
68
|
+
if args.cmd == "engines":
|
|
69
|
+
return _cmd_engines(args)
|
|
70
|
+
parser.error(f"unknown command: {args.cmd}")
|
|
71
|
+
return 2 # unreachable
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
75
|
+
config = LooperConfig.load()
|
|
76
|
+
engine, environment = _load_engine_and_environment(config, engine_override=args.engine)
|
|
77
|
+
looper = GooseLooper(
|
|
78
|
+
engine=engine,
|
|
79
|
+
environment=environment,
|
|
80
|
+
config=config,
|
|
81
|
+
model=args.model,
|
|
82
|
+
save=not args.no_save,
|
|
83
|
+
validate=not args.no_validate,
|
|
84
|
+
review_only=args.review_only,
|
|
85
|
+
review_overlays=[Path(p) for p in args.review_overlay],
|
|
86
|
+
summary_overlays=[Path(p) for p in args.summary_overlay],
|
|
87
|
+
)
|
|
88
|
+
result = looper.begin_loop()
|
|
89
|
+
return 0 if result.get("review_status") != "error" else 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_recipe(args: argparse.Namespace) -> int:
|
|
93
|
+
if not args.resolve:
|
|
94
|
+
print("usage: gooseloop recipe --resolve NAME [--overlay PATH ...]",
|
|
95
|
+
file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
config = LooperConfig.load()
|
|
98
|
+
base_path = (config.anchor / args.resolve).resolve()
|
|
99
|
+
if not base_path.exists():
|
|
100
|
+
# Try with .yaml suffix if user passed a bare name.
|
|
101
|
+
candidate = base_path.with_suffix(".yaml")
|
|
102
|
+
if candidate.exists():
|
|
103
|
+
base_path = candidate
|
|
104
|
+
else:
|
|
105
|
+
print(f"recipe not found: {base_path}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
local = base_path.with_suffix("").with_name(base_path.stem + ".local").with_suffix(".yaml")
|
|
108
|
+
overlay_paths = [Path(p) for p in args.overlay]
|
|
109
|
+
merged = load_layered_recipe(
|
|
110
|
+
base_path,
|
|
111
|
+
local_path=local if local.exists() else None,
|
|
112
|
+
overlay_paths=overlay_paths,
|
|
113
|
+
)
|
|
114
|
+
print(resolved_recipe_yaml(merged))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_engines(args: argparse.Namespace) -> int:
|
|
119
|
+
config = LooperConfig.load()
|
|
120
|
+
print(f"engine_module: {config.engine_module}")
|
|
121
|
+
try:
|
|
122
|
+
mod = importlib.import_module(config.engine_module)
|
|
123
|
+
engine_cls = getattr(mod, "engine", None)
|
|
124
|
+
if engine_cls is None:
|
|
125
|
+
print(" (module has no `engine` attribute)", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
print(f"engine class: {engine_cls.__module__}.{engine_cls.__name__}")
|
|
128
|
+
except ImportError as e:
|
|
129
|
+
print(f" import failed: {e}", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_engine_and_environment(
|
|
135
|
+
config: LooperConfig,
|
|
136
|
+
*,
|
|
137
|
+
engine_override: str | None = None,
|
|
138
|
+
) -> tuple[Engine, Environment | None]:
|
|
139
|
+
"""Import the engine module; instantiate its `engine` attribute.
|
|
140
|
+
|
|
141
|
+
Convention: an engine package exposes a module-level `engine`
|
|
142
|
+
callable (often the class itself) and optionally an `environment`
|
|
143
|
+
callable. Both are instantiated with no arguments by default.
|
|
144
|
+
Engines requiring constructor arguments should ship factory
|
|
145
|
+
callables in those slots.
|
|
146
|
+
|
|
147
|
+
`engine_override` (from `-e`/`--engine`) supersedes
|
|
148
|
+
`config.engine_module` when provided.
|
|
149
|
+
"""
|
|
150
|
+
module_name = engine_override or config.engine_module
|
|
151
|
+
sys.path.insert(0, str(config.anchor))
|
|
152
|
+
try:
|
|
153
|
+
mod = importlib.import_module(module_name)
|
|
154
|
+
except ImportError as e:
|
|
155
|
+
raise SystemExit(
|
|
156
|
+
f"gooseloop: cannot import engine module {module_name!r}: {e}"
|
|
157
|
+
)
|
|
158
|
+
engine_obj = getattr(mod, "engine", None)
|
|
159
|
+
if engine_obj is None:
|
|
160
|
+
raise SystemExit(
|
|
161
|
+
f"gooseloop: engine module {module_name!r} has no `engine` attribute"
|
|
162
|
+
)
|
|
163
|
+
engine = engine_obj() if callable(engine_obj) and not isinstance(engine_obj, Engine) else engine_obj
|
|
164
|
+
if not isinstance(engine, Engine):
|
|
165
|
+
raise SystemExit(
|
|
166
|
+
f"gooseloop: {module_name}.engine did not yield an Engine instance"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
env_obj = getattr(mod, "environment", None)
|
|
170
|
+
environment: Environment | None = None
|
|
171
|
+
if env_obj is not None:
|
|
172
|
+
environment = env_obj() if callable(env_obj) and not isinstance(env_obj, Environment) else env_obj
|
|
173
|
+
if not isinstance(environment, Environment):
|
|
174
|
+
raise SystemExit(
|
|
175
|
+
f"gooseloop: {module_name}.environment did not yield an Environment instance"
|
|
176
|
+
)
|
|
177
|
+
return engine, environment
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
sys.exit(main())
|
gooseloop/artifact.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Versioned artifact contracts: how engines compose without knowing each other.
|
|
2
|
+
|
|
3
|
+
Per PROTOCOL.md §12. When one engine's output is another engine's input, the
|
|
4
|
+
artifact file is the interface between them, and an interface needs a version.
|
|
5
|
+
The producing engine stamps a `schema_version` key into the artifact; the
|
|
6
|
+
consuming engine checks it at read time with check_artifact_version().
|
|
7
|
+
|
|
8
|
+
The semantics deliberately mirror the review protocol_version check in
|
|
9
|
+
protocol.py, because it is the same problem one level up:
|
|
10
|
+
|
|
11
|
+
- Same major, any minor: compatible. Additive schema changes (a new
|
|
12
|
+
optional field, a new enum value the reader ignores) bump the minor.
|
|
13
|
+
- Different major, or an unparseable version: refused loudly with
|
|
14
|
+
ArtifactVersionError. A major mismatch means the reader may
|
|
15
|
+
misinterpret every entry; limping along is the silent time bomb.
|
|
16
|
+
- Missing version: the artifact is still read, and a problem string is
|
|
17
|
+
returned nudging the operator to stamp the file. Fail-safe runs in the
|
|
18
|
+
KEEP direction: data we cannot positively classify is kept and named
|
|
19
|
+
out loud, never refused on a technicality. (Pre-versioning artifacts
|
|
20
|
+
sealed by hand exist; they should not stop working the day the
|
|
21
|
+
contract gains a version key.)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ArtifactVersionError(Exception):
|
|
30
|
+
"""The artifact declares a schema version this reader cannot honour."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_artifact_version(
|
|
34
|
+
data: dict[str, Any],
|
|
35
|
+
supported: str,
|
|
36
|
+
*,
|
|
37
|
+
key: str = "schema_version",
|
|
38
|
+
what: str = "artifact",
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""Check a parsed artifact's declared schema version against `supported`.
|
|
41
|
+
|
|
42
|
+
Returns a list of problem strings (empty when the version is present and
|
|
43
|
+
compatible). Raises ArtifactVersionError on a major mismatch or an
|
|
44
|
+
unparseable declared version.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
data: the parsed artifact (the top-level mapping).
|
|
48
|
+
supported: the version this reader ships, e.g. "1.0".
|
|
49
|
+
key: the version key inside the artifact.
|
|
50
|
+
what: human name for the artifact, used in messages.
|
|
51
|
+
"""
|
|
52
|
+
supported_major = _major(supported, what=f"supported version for {what}")
|
|
53
|
+
|
|
54
|
+
declared = data.get(key)
|
|
55
|
+
if declared is None or str(declared).strip() == "":
|
|
56
|
+
return [
|
|
57
|
+
f"{what} has no {key}; assuming {supported} - "
|
|
58
|
+
f'add `{key} = "{supported}"` to the file'
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
declared = str(declared).strip()
|
|
62
|
+
try:
|
|
63
|
+
declared_major = _major(declared, what=what)
|
|
64
|
+
except ArtifactVersionError:
|
|
65
|
+
raise ArtifactVersionError(
|
|
66
|
+
f"{what} declares {key} {declared!r}, which is not parseable; "
|
|
67
|
+
f"this reader supports major {supported_major}"
|
|
68
|
+
) from None
|
|
69
|
+
|
|
70
|
+
if declared_major != supported_major:
|
|
71
|
+
raise ArtifactVersionError(
|
|
72
|
+
f"{what} declares {key} {declared!r} (major {declared_major}); "
|
|
73
|
+
f"this reader supports major {supported_major} only"
|
|
74
|
+
)
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _major(version: str, *, what: str) -> int:
|
|
79
|
+
try:
|
|
80
|
+
return int(str(version).split(".", 1)[0])
|
|
81
|
+
except (ValueError, IndexError):
|
|
82
|
+
raise ArtifactVersionError(f"{what}: version {version!r} is not parseable")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""BranchPolicy — per-recipe rules the framework applies when building body Phases.
|
|
2
|
+
|
|
3
|
+
Per ADR 0007 and PROTOCOL.md §5. The framework consults the engine's
|
|
4
|
+
branch_policies dict for each routing[] entry the review emitted, looking
|
|
5
|
+
up by recipe name. Recipes with no entry get BranchPolicy() defaults
|
|
6
|
+
(no skip, no path tracking, no extra predicate, intent unchecked).
|
|
7
|
+
|
|
8
|
+
Authors of an engine register policies like:
|
|
9
|
+
|
|
10
|
+
class MyEngine(Engine):
|
|
11
|
+
branch_policies = {
|
|
12
|
+
"to-outreach": BranchPolicy(
|
|
13
|
+
skip_when=lambda p: (outreach_dir / f"{p['slug']}.md").exists(),
|
|
14
|
+
output_path=lambda p: outreach_dir / f"{p['slug']}.md",
|
|
15
|
+
intent="produce",
|
|
16
|
+
),
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable, Literal, Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Intent = Literal["produce", "edit", "edit-or-produce"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class BranchPolicy:
|
|
30
|
+
"""Per-recipe rules. All fields optional; the default is the no-op policy.
|
|
31
|
+
|
|
32
|
+
skip_when: called with the routing entry's params dict.
|
|
33
|
+
Truthy return skips the phase. A str return is used as the
|
|
34
|
+
skip reason in the session log.
|
|
35
|
+
output_path: called with the params dict to compute the deterministic
|
|
36
|
+
file path the recipe is expected to write. The framework uses this
|
|
37
|
+
to derive a default success predicate (file_nonempty) and to log
|
|
38
|
+
the path on success.
|
|
39
|
+
predicate: explicit success predicate override. Takes the recipe's
|
|
40
|
+
stdout. If unset and output_path is set, the framework derives a
|
|
41
|
+
file_nonempty predicate from output_path.
|
|
42
|
+
intent: declarative tag for the recipe's intent against output_path.
|
|
43
|
+
Currently informational; reserved for future intent-reconciliation
|
|
44
|
+
checks. None = unchecked.
|
|
45
|
+
"""
|
|
46
|
+
skip_when: Optional[Callable[[dict[str, Any]], "bool | str | None"]] = None
|
|
47
|
+
output_path: Optional[Callable[[dict[str, Any]], Optional[Path]]] = None
|
|
48
|
+
predicate: Optional[Callable[[str], bool]] = None
|
|
49
|
+
intent: Optional[Intent] = None
|
gooseloop/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""LooperConfig — value object for gooseloop.toml.
|
|
2
|
+
|
|
3
|
+
Loaded once at the top of a run via LooperConfig.load() and passed
|
|
4
|
+
explicitly. No module-level singleton, no clear-cache shims, no global
|
|
5
|
+
state. Tests construct LooperConfig directly with overrides.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONFIG_FILENAME = "gooseloop.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULTS: dict[str, Any] = {
|
|
19
|
+
"gooseloop": {
|
|
20
|
+
"default_model": "openrouter/owl-alpha",
|
|
21
|
+
"sessions_dir": "reviews/sessions",
|
|
22
|
+
"engine_module": "engines.hello_world",
|
|
23
|
+
"environment_config": "",
|
|
24
|
+
"max_queue_depth": 50,
|
|
25
|
+
"review_recipe": "review.yaml",
|
|
26
|
+
"summary_recipe": "summary.yaml",
|
|
27
|
+
"retry": {
|
|
28
|
+
"max_retries": 6,
|
|
29
|
+
"base_delay": 5,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RetrySettings:
|
|
37
|
+
max_retries: int = 6
|
|
38
|
+
base_delay: int = 5
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class LooperConfig:
|
|
43
|
+
"""Resolved gooseloop.toml. Construct via LooperConfig.load(); never global."""
|
|
44
|
+
default_model: str = "openrouter/owl-alpha"
|
|
45
|
+
sessions_dir: Path = field(default_factory=lambda: Path("reviews/sessions"))
|
|
46
|
+
engine_module: str = "engines.hello_world"
|
|
47
|
+
environment_config: Path | None = None
|
|
48
|
+
max_queue_depth: int = 50
|
|
49
|
+
review_recipe: str = "review.yaml"
|
|
50
|
+
summary_recipe: str = "summary.yaml"
|
|
51
|
+
retry: RetrySettings = field(default_factory=RetrySettings)
|
|
52
|
+
anchor: Path = field(default_factory=Path.cwd)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def load(cls, anchor: Path | None = None, *, warn_on_missing: bool = True) -> "LooperConfig":
|
|
56
|
+
"""Load gooseloop.toml from `anchor` (default: cwd). Missing file = defaults."""
|
|
57
|
+
anchor = (anchor or Path.cwd()).resolve()
|
|
58
|
+
path = anchor / CONFIG_FILENAME
|
|
59
|
+
if path.exists():
|
|
60
|
+
with open(path, "rb") as f:
|
|
61
|
+
raw = tomllib.load(f)
|
|
62
|
+
merged = _deep_merge(DEFAULTS, raw)
|
|
63
|
+
else:
|
|
64
|
+
if warn_on_missing:
|
|
65
|
+
print(
|
|
66
|
+
f"[gooseloop] {CONFIG_FILENAME} not found in {anchor}; using defaults.",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
merged = DEFAULTS
|
|
70
|
+
return cls._from_merged(merged, anchor)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _from_merged(cls, merged: dict[str, Any], anchor: Path) -> "LooperConfig":
|
|
74
|
+
section = merged["gooseloop"]
|
|
75
|
+
env_cfg = section.get("environment_config", "") or ""
|
|
76
|
+
return cls(
|
|
77
|
+
default_model=section["default_model"],
|
|
78
|
+
sessions_dir=_resolve(section["sessions_dir"], anchor),
|
|
79
|
+
engine_module=section["engine_module"],
|
|
80
|
+
environment_config=_resolve(env_cfg, anchor) if env_cfg else None,
|
|
81
|
+
max_queue_depth=int(section["max_queue_depth"]),
|
|
82
|
+
review_recipe=section.get("review_recipe", "review.yaml"),
|
|
83
|
+
summary_recipe=section.get("summary_recipe", "summary.yaml"),
|
|
84
|
+
retry=RetrySettings(
|
|
85
|
+
max_retries=int(section["retry"]["max_retries"]),
|
|
86
|
+
base_delay=int(section["retry"]["base_delay"]),
|
|
87
|
+
),
|
|
88
|
+
anchor=anchor,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
93
|
+
out = dict(base)
|
|
94
|
+
for k, v in override.items():
|
|
95
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
96
|
+
out[k] = _deep_merge(out[k], v)
|
|
97
|
+
else:
|
|
98
|
+
out[k] = v
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve(path_str: str, anchor: Path) -> Path:
|
|
103
|
+
p = Path(path_str)
|
|
104
|
+
return p if p.is_absolute() else (anchor / p).resolve()
|