whiletrue 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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/core/config.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal, NoReturn, cast
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt, ValidationError
|
|
10
|
+
|
|
11
|
+
from whiletrue.core.duration import parse_duration
|
|
12
|
+
from whiletrue.core.outcomes import OnFail
|
|
13
|
+
from whiletrue.core.runner import AgentLimits
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SourceLoc:
|
|
18
|
+
file: str
|
|
19
|
+
line: int
|
|
20
|
+
col: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(Exception):
|
|
24
|
+
def __init__(self, loc: SourceLoc, message: str) -> None:
|
|
25
|
+
super().__init__(f"{loc.file}:{loc.line}:{loc.col}: {message}")
|
|
26
|
+
self.file = loc.file
|
|
27
|
+
self.line = loc.line
|
|
28
|
+
self.col = loc.col
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PipelineNode:
|
|
33
|
+
id: str
|
|
34
|
+
#: Module or strategy id, e.g. "shell" or "loop".
|
|
35
|
+
use: str
|
|
36
|
+
config: dict[str, Any]
|
|
37
|
+
children: list[PipelineNode]
|
|
38
|
+
#: Outcome/data port -> sibling node id.
|
|
39
|
+
wires: dict[str, str]
|
|
40
|
+
loc: SourceLoc
|
|
41
|
+
#: Declared port caps (`in`/`out`), set on expanded composites so wiring can be checked.
|
|
42
|
+
ports: dict[str, str] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PipelineConfig:
|
|
47
|
+
file: str
|
|
48
|
+
name: str
|
|
49
|
+
uses: list[str]
|
|
50
|
+
on_idle: Literal["exit", "watch"]
|
|
51
|
+
on_fail: OnFail
|
|
52
|
+
#: Cache results of pure/deterministic modules by input hash.
|
|
53
|
+
cache: bool
|
|
54
|
+
#: How many items run at once.
|
|
55
|
+
parallel: int
|
|
56
|
+
#: Cap on live agent sessions across the whole run (None: unlimited).
|
|
57
|
+
max_agents: int | None
|
|
58
|
+
#: Passed through for providers (source, comments, ...).
|
|
59
|
+
extra: dict[str, Any]
|
|
60
|
+
root: PipelineNode
|
|
61
|
+
#: Composite files that were expanded into `root` (resolved paths), for hashing.
|
|
62
|
+
sources: list[str] = field(default_factory=lambda: [])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Path_ = list[str | int]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Loader(yaml.SafeLoader):
|
|
69
|
+
"""YAML 1.2-ish scalars: only true/false are booleans and timestamps stay strings."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_Loader.yaml_implicit_resolvers = {
|
|
73
|
+
first: [
|
|
74
|
+
(tag, regexp)
|
|
75
|
+
for tag, regexp in resolvers
|
|
76
|
+
if tag not in ("tag:yaml.org,2002:bool", "tag:yaml.org,2002:timestamp")
|
|
77
|
+
]
|
|
78
|
+
for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
79
|
+
}
|
|
80
|
+
_Loader.add_implicit_resolver( # pyright: ignore[reportUnknownMemberType]
|
|
81
|
+
"tag:yaml.org,2002:bool",
|
|
82
|
+
re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
|
|
83
|
+
list("tTfF"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Located:
|
|
88
|
+
"""A parsed YAML document that can answer "where is this path?" for error messages."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, text: str, file: str, line_offset: int = 0) -> None:
|
|
91
|
+
self.file = file
|
|
92
|
+
self.line_offset = line_offset
|
|
93
|
+
loader = _Loader(text)
|
|
94
|
+
try:
|
|
95
|
+
self.root: yaml.Node | None = loader.get_single_node()
|
|
96
|
+
self.value: Any = (
|
|
97
|
+
loader.construct_document(self.root) # pyright: ignore[reportUnknownMemberType]
|
|
98
|
+
if self.root
|
|
99
|
+
else None
|
|
100
|
+
)
|
|
101
|
+
except yaml.YAMLError as error:
|
|
102
|
+
mark = getattr(error, "problem_mark", None)
|
|
103
|
+
problem = getattr(error, "problem", None) or str(error)
|
|
104
|
+
line = (mark.line if mark else 0) + 1 + line_offset
|
|
105
|
+
col = (mark.column if mark else 0) + 1
|
|
106
|
+
raise ConfigError(SourceLoc(file, line, col), str(problem).split("\n")[0]) from error
|
|
107
|
+
finally:
|
|
108
|
+
loader.dispose()
|
|
109
|
+
|
|
110
|
+
def _node_at(self, path: Path_) -> yaml.Node | None:
|
|
111
|
+
node = self.root
|
|
112
|
+
for key in path:
|
|
113
|
+
if isinstance(node, yaml.MappingNode):
|
|
114
|
+
node = next(
|
|
115
|
+
(v for k, v in node.value if isinstance(k, yaml.ScalarNode) and k.value == key),
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
118
|
+
elif isinstance(node, yaml.SequenceNode) and isinstance(key, int):
|
|
119
|
+
node = node.value[key] if 0 <= key < len(node.value) else None
|
|
120
|
+
else:
|
|
121
|
+
node = None
|
|
122
|
+
if node is None:
|
|
123
|
+
return None
|
|
124
|
+
return node
|
|
125
|
+
|
|
126
|
+
def loc(self, path: Path_) -> SourceLoc:
|
|
127
|
+
# Fall back to the nearest existing ancestor.
|
|
128
|
+
for end in range(len(path), 0, -1):
|
|
129
|
+
node = self._node_at(path[:end])
|
|
130
|
+
if node is not None:
|
|
131
|
+
return self._loc_of(node)
|
|
132
|
+
return SourceLoc(self.file, 1 + self.line_offset, 1)
|
|
133
|
+
|
|
134
|
+
def _loc_of(self, node: yaml.Node) -> SourceLoc:
|
|
135
|
+
mark = node.start_mark
|
|
136
|
+
return SourceLoc(self.file, mark.line + 1 + self.line_offset, mark.column + 1)
|
|
137
|
+
|
|
138
|
+
def fail(self, path: Path_, message: str) -> NoReturn:
|
|
139
|
+
raise ConfigError(self.loc(path), message)
|
|
140
|
+
|
|
141
|
+
def validate[T: BaseModel](self, model: type[T], value: Any, base: Path_ | None = None) -> T:
|
|
142
|
+
try:
|
|
143
|
+
return model.model_validate(value)
|
|
144
|
+
except ValidationError as error:
|
|
145
|
+
issue = error.errors()[0]
|
|
146
|
+
loc = list(issue["loc"])
|
|
147
|
+
where = f' at "{".".join(str(p) for p in loc)}"' if loc else ""
|
|
148
|
+
self.fail([*(base or []), *loc], f"{issue['msg']}{where}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_RESERVED = {"id", "wires", "do"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _TopLevel(BaseModel):
|
|
155
|
+
model_config = ConfigDict(extra="allow")
|
|
156
|
+
|
|
157
|
+
name: str | None = Field(default=None, min_length=1)
|
|
158
|
+
uses: list[str] = Field(default_factory=lambda: [])
|
|
159
|
+
on_idle: Literal["exit", "watch"] = "exit"
|
|
160
|
+
on_fail: Literal["park", "drop", "propagate"] = "park"
|
|
161
|
+
cache: bool = True
|
|
162
|
+
parallel: int = Field(default=1, ge=1)
|
|
163
|
+
max_agents: int | None = Field(default=None, ge=1)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _build_node(
|
|
167
|
+
located: _Located, value: Any, path: Path_, default_id: str, seen: dict[str, SourceLoc]
|
|
168
|
+
) -> PipelineNode:
|
|
169
|
+
if not isinstance(value, dict):
|
|
170
|
+
located.fail(path, "A pipeline step must be a mapping")
|
|
171
|
+
step: dict[str, Any] = value # pyright: ignore[reportUnknownVariableType]
|
|
172
|
+
keys = [k for k in step if k not in _RESERVED]
|
|
173
|
+
if len(keys) != 1:
|
|
174
|
+
located.fail(
|
|
175
|
+
path,
|
|
176
|
+
"A step needs a module key (e.g. `shell: {...}`)"
|
|
177
|
+
if not keys
|
|
178
|
+
else f"A step must have exactly one module key, found: {', '.join(map(str, keys))}",
|
|
179
|
+
)
|
|
180
|
+
use = str(keys[0])
|
|
181
|
+
raw_config = step[keys[0]]
|
|
182
|
+
if raw_config is not None and not isinstance(raw_config, dict):
|
|
183
|
+
located.fail([*path, use], f'Config for "{use}" must be a mapping')
|
|
184
|
+
config: dict[str, Any] = dict(raw_config) if raw_config else {} # pyright: ignore[reportUnknownArgumentType]
|
|
185
|
+
|
|
186
|
+
# `do` may sit beside the module key or inside its config (strategies); hoist the latter.
|
|
187
|
+
do_path: Path_ = [*path, "do"]
|
|
188
|
+
raw_do = step.get("do")
|
|
189
|
+
if raw_do is None and "do" in config:
|
|
190
|
+
raw_do = config.pop("do")
|
|
191
|
+
do_path = [*path, use, "do"]
|
|
192
|
+
if raw_do is not None and not isinstance(raw_do, list):
|
|
193
|
+
located.fail(do_path, "`do` must be a list of steps")
|
|
194
|
+
|
|
195
|
+
explicit_id = step.get("id")
|
|
196
|
+
if "id" in step and (not isinstance(explicit_id, str) or explicit_id == ""):
|
|
197
|
+
located.fail([*path, "id"], "`id` must be a non-empty string")
|
|
198
|
+
node_id: str = explicit_id if isinstance(explicit_id, str) else default_id
|
|
199
|
+
loc = located.loc(path)
|
|
200
|
+
if node_id in seen:
|
|
201
|
+
located.fail(
|
|
202
|
+
path, f'Duplicate step id "{node_id}" (first defined at line {seen[node_id].line})'
|
|
203
|
+
)
|
|
204
|
+
seen[node_id] = loc
|
|
205
|
+
|
|
206
|
+
raw_wires = step.get("wires")
|
|
207
|
+
if raw_wires is not None and not (
|
|
208
|
+
isinstance(raw_wires, dict) and all(isinstance(t, str) for t in raw_wires.values()) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
|
|
209
|
+
):
|
|
210
|
+
located.fail([*path, "wires"], "`wires` must map port names to step ids")
|
|
211
|
+
|
|
212
|
+
children = [
|
|
213
|
+
_build_node(located, child, [*do_path, i], f"{node_id}.{i}", seen)
|
|
214
|
+
for i, child in enumerate(raw_do or []) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
|
215
|
+
]
|
|
216
|
+
return PipelineNode(
|
|
217
|
+
id=node_id,
|
|
218
|
+
use=use,
|
|
219
|
+
config=config,
|
|
220
|
+
children=children,
|
|
221
|
+
wires=dict(raw_wires) if raw_wires else {}, # pyright: ignore[reportUnknownArgumentType]
|
|
222
|
+
loc=loc,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _check_wires(node: PipelineNode) -> None:
|
|
227
|
+
"""Wire targets must be siblings."""
|
|
228
|
+
siblings = {c.id for c in node.children}
|
|
229
|
+
for child in node.children:
|
|
230
|
+
for port, target in child.wires.items():
|
|
231
|
+
if target not in siblings:
|
|
232
|
+
raise ConfigError(
|
|
233
|
+
child.loc, f'Wire "{port}" targets unknown sibling step "{target}"'
|
|
234
|
+
)
|
|
235
|
+
_check_wires(child)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_root(located: _Located, pipeline: Any, path: Path_) -> PipelineNode:
|
|
239
|
+
"""Builds the node tree for a `pipeline:` value (a single step or an implicit sequence)."""
|
|
240
|
+
seen: dict[str, SourceLoc] = {}
|
|
241
|
+
if isinstance(pipeline, list):
|
|
242
|
+
seen["root"] = located.loc(path)
|
|
243
|
+
children = [
|
|
244
|
+
_build_node(located, child, [*path, i], f"root.{i}", seen)
|
|
245
|
+
for i, child in enumerate(pipeline) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
|
246
|
+
]
|
|
247
|
+
root = PipelineNode(
|
|
248
|
+
id="root", use="sequence", config={}, children=children, wires={}, loc=located.loc(path)
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
root = _build_node(located, pipeline, path, "root", seen)
|
|
252
|
+
_check_wires(root)
|
|
253
|
+
return root
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parse_pipeline(
|
|
257
|
+
text: str, file: str = "whiletrue.yaml", *, base_dir: Path | None = None
|
|
258
|
+
) -> PipelineConfig:
|
|
259
|
+
"""Parses a pipeline. With `base_dir`, `composite:` steps are loaded and expanded in place."""
|
|
260
|
+
located = _Located(text, file)
|
|
261
|
+
raw = located.value
|
|
262
|
+
if not isinstance(raw, dict):
|
|
263
|
+
located.fail([], "whiletrue.yaml must be a mapping")
|
|
264
|
+
top = located.validate(_TopLevel, raw)
|
|
265
|
+
if "pipeline" not in raw:
|
|
266
|
+
located.fail([], "Missing required key `pipeline`")
|
|
267
|
+
|
|
268
|
+
extra: dict[str, Any] = {k: v for k, v in (top.model_extra or {}).items() if k != "pipeline"}
|
|
269
|
+
root = build_root(located, cast(dict[str, Any], raw)["pipeline"], ["pipeline"])
|
|
270
|
+
sources: list[str] = []
|
|
271
|
+
if base_dir is not None:
|
|
272
|
+
from whiletrue.core.composite import expand_composites # noqa: PLC0415 (import cycle)
|
|
273
|
+
|
|
274
|
+
root = expand_composites(root, base_dir, sources)
|
|
275
|
+
|
|
276
|
+
return PipelineConfig(
|
|
277
|
+
file=file,
|
|
278
|
+
name=top.name or Path(file).resolve().parent.name,
|
|
279
|
+
uses=top.uses,
|
|
280
|
+
on_idle=top.on_idle,
|
|
281
|
+
on_fail=top.on_fail,
|
|
282
|
+
cache=top.cache,
|
|
283
|
+
parallel=top.parallel,
|
|
284
|
+
max_agents=top.max_agents,
|
|
285
|
+
extra=extra,
|
|
286
|
+
root=root,
|
|
287
|
+
sources=sources,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_pipeline(path: str | Path) -> PipelineConfig:
|
|
292
|
+
return parse_pipeline(
|
|
293
|
+
Path(path).read_text(encoding="utf-8"), str(path), base_dir=Path(path).resolve().parent
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---- Composites (reusable sub-pipelines) -------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _ParamSpec(BaseModel):
|
|
301
|
+
model_config = ConfigDict(extra="forbid")
|
|
302
|
+
|
|
303
|
+
default: Any = None
|
|
304
|
+
required: bool = False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _CompositeTop(BaseModel):
|
|
308
|
+
model_config = ConfigDict(extra="forbid")
|
|
309
|
+
|
|
310
|
+
composite: str = Field(min_length=1)
|
|
311
|
+
params: dict[str, _ParamSpec | None] = Field(default_factory=lambda: {})
|
|
312
|
+
ports: dict[str, str] = Field(default_factory=lambda: {})
|
|
313
|
+
pipeline: Any
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class ParamSpec:
|
|
318
|
+
default: Any = None
|
|
319
|
+
required: bool = False
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class CompositeDefinition:
|
|
324
|
+
file: str
|
|
325
|
+
name: str
|
|
326
|
+
params: dict[str, ParamSpec]
|
|
327
|
+
#: Declared port caps, keyed `in` / `out`.
|
|
328
|
+
ports: dict[str, str]
|
|
329
|
+
root: PipelineNode
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def parse_composite(text: str, file: str) -> CompositeDefinition:
|
|
333
|
+
located = _Located(text, file)
|
|
334
|
+
raw: Any = located.value
|
|
335
|
+
if not isinstance(raw, dict):
|
|
336
|
+
located.fail([], "A composite file must be a mapping")
|
|
337
|
+
if "pipeline" not in raw:
|
|
338
|
+
located.fail([], "Missing required key `pipeline`")
|
|
339
|
+
top = located.validate(_CompositeTop, raw)
|
|
340
|
+
unknown = [k for k in top.ports if k not in ("in", "out")]
|
|
341
|
+
if unknown:
|
|
342
|
+
located.fail(["ports", unknown[0]], 'Composite ports must be named "in" or "out"')
|
|
343
|
+
return CompositeDefinition(
|
|
344
|
+
file=file,
|
|
345
|
+
name=top.composite,
|
|
346
|
+
params={
|
|
347
|
+
name: ParamSpec(spec.default, spec.required) if spec else ParamSpec()
|
|
348
|
+
for name, spec in top.params.items()
|
|
349
|
+
},
|
|
350
|
+
ports=top.ports,
|
|
351
|
+
root=build_root(located, top.pipeline, ["pipeline"]),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def load_composite(path: str | Path) -> CompositeDefinition:
|
|
356
|
+
return parse_composite(Path(path).read_text(encoding="utf-8"), str(path))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---- Agent modules (markdown with frontmatter) -------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class _LimitsFrontmatter(BaseModel):
|
|
363
|
+
budget_usd: PositiveFloat | None = None
|
|
364
|
+
turns: PositiveInt | None = None
|
|
365
|
+
wall: str | int | float | None = None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class _AgentFrontmatter(BaseModel):
|
|
369
|
+
agent: str = Field(min_length=1)
|
|
370
|
+
runner: str = "claude-code-headless"
|
|
371
|
+
model: str | None = None
|
|
372
|
+
session: str = "fresh"
|
|
373
|
+
tools: list[str] | None = None
|
|
374
|
+
permission_mode: str | None = None
|
|
375
|
+
inputs: dict[str, str] = Field(default_factory=lambda: {})
|
|
376
|
+
outputs: dict[str, str] = Field(default_factory=lambda: {})
|
|
377
|
+
context: dict[str, Any] = Field(default_factory=lambda: {})
|
|
378
|
+
limits: _LimitsFrontmatter | None = None
|
|
379
|
+
fork: bool = False
|
|
380
|
+
on_done: Literal["close", "keep"] = "close"
|
|
381
|
+
add_dirs: list[str] | None = None
|
|
382
|
+
append_system_prompt_file: str | None = None
|
|
383
|
+
mcp_config: list[str] | None = None
|
|
384
|
+
bare: bool = False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass
|
|
388
|
+
class AgentDefinition:
|
|
389
|
+
file: str
|
|
390
|
+
name: str
|
|
391
|
+
prompt: str
|
|
392
|
+
runner: str = "claude-code-headless"
|
|
393
|
+
model: str | None = None
|
|
394
|
+
session: str = "fresh"
|
|
395
|
+
tools: list[str] | None = None
|
|
396
|
+
permission_mode: str | None = None
|
|
397
|
+
inputs: dict[str, str] = field(default_factory=lambda: {})
|
|
398
|
+
outputs: dict[str, str] = field(default_factory=lambda: {})
|
|
399
|
+
context: dict[str, Any] = field(default_factory=lambda: {})
|
|
400
|
+
limits: AgentLimits | None = None
|
|
401
|
+
#: With a shared `session`: branch from it without changing it.
|
|
402
|
+
fork: bool = False
|
|
403
|
+
#: Interactive runners: close the pane when the step ends, or keep it on screen.
|
|
404
|
+
on_done: Literal["close", "keep"] = "close"
|
|
405
|
+
add_dirs: list[str] | None = None
|
|
406
|
+
append_system_prompt_file: str | None = None
|
|
407
|
+
mcp_config: list[str] | None = None
|
|
408
|
+
bare: bool = False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
_FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)(.*)\Z", re.DOTALL)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_agent_definition(text: str, file: str) -> AgentDefinition:
|
|
415
|
+
match = _FRONTMATTER.match(text)
|
|
416
|
+
if match is None:
|
|
417
|
+
raise ConfigError(
|
|
418
|
+
SourceLoc(file, 1, 1),
|
|
419
|
+
"Agent file must start with a YAML frontmatter block delimited by ---",
|
|
420
|
+
)
|
|
421
|
+
located = _Located(match.group(1), file, line_offset=1)
|
|
422
|
+
frontmatter: Any = located.value
|
|
423
|
+
if not isinstance(frontmatter, dict):
|
|
424
|
+
located.fail([], "Agent frontmatter must be a mapping")
|
|
425
|
+
fm = located.validate(_AgentFrontmatter, cast(dict[str, Any], frontmatter))
|
|
426
|
+
|
|
427
|
+
wall_ms: int | None = None
|
|
428
|
+
if fm.limits is not None and fm.limits.wall is not None:
|
|
429
|
+
try:
|
|
430
|
+
wall_ms = parse_duration(fm.limits.wall)
|
|
431
|
+
except ValueError as error:
|
|
432
|
+
located.fail(["limits", "wall"], str(error))
|
|
433
|
+
limits = (
|
|
434
|
+
AgentLimits(budget_usd=fm.limits.budget_usd, turns=fm.limits.turns, wall_ms=wall_ms)
|
|
435
|
+
if fm.limits is not None
|
|
436
|
+
else None
|
|
437
|
+
)
|
|
438
|
+
return AgentDefinition(
|
|
439
|
+
file=file,
|
|
440
|
+
name=fm.agent,
|
|
441
|
+
runner=fm.runner,
|
|
442
|
+
model=fm.model,
|
|
443
|
+
session=fm.session,
|
|
444
|
+
tools=fm.tools,
|
|
445
|
+
permission_mode=fm.permission_mode,
|
|
446
|
+
inputs=fm.inputs,
|
|
447
|
+
outputs=fm.outputs,
|
|
448
|
+
context=fm.context,
|
|
449
|
+
limits=limits,
|
|
450
|
+
fork=fm.fork,
|
|
451
|
+
on_done=fm.on_done,
|
|
452
|
+
add_dirs=fm.add_dirs,
|
|
453
|
+
append_system_prompt_file=fm.append_system_prompt_file,
|
|
454
|
+
mcp_config=fm.mcp_config,
|
|
455
|
+
bare=fm.bare,
|
|
456
|
+
prompt=match.group(2).strip(),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def load_agent_definition(path: str | Path) -> AgentDefinition:
|
|
461
|
+
return parse_agent_definition(Path(path).read_text(encoding="utf-8"), str(path))
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
10
|
+
|
|
11
|
+
from whiletrue.abort import AbortSignal
|
|
12
|
+
from whiletrue.core.events import EventBus
|
|
13
|
+
from whiletrue.core.registry import Registry
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from whiletrue.core.config import PipelineNode
|
|
17
|
+
from whiletrue.core.module import Emit, PortValues
|
|
18
|
+
from whiletrue.core.outcomes import OutcomePolicy
|
|
19
|
+
from whiletrue.core.store import StateStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Locks:
|
|
23
|
+
"""Named locks shared by a run's steps (`repo:main` so only one item merges at a time)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
27
|
+
|
|
28
|
+
def hold(self, name: str) -> asyncio.Lock:
|
|
29
|
+
"""Use as `async with locks.hold("repo:main"):`."""
|
|
30
|
+
return self._locks.setdefault(name, asyncio.Lock())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Resource:
|
|
35
|
+
name: str
|
|
36
|
+
dispose: Callable[[], Awaitable[None] | None]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Scope:
|
|
40
|
+
"""Key-value context inherited by children, plus owned resources (workspaces, sessions, panes).
|
|
41
|
+
|
|
42
|
+
Resources are released when the scope exits, which makes "one worktree per issue" a matter of
|
|
43
|
+
where you declare it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, parent: Scope | None = None) -> None:
|
|
47
|
+
self.parent = parent
|
|
48
|
+
self._values: dict[str, Any] = {}
|
|
49
|
+
self._resources: list[Resource] = []
|
|
50
|
+
self._disposed = False
|
|
51
|
+
|
|
52
|
+
def get(self, key: str) -> Any:
|
|
53
|
+
if key in self._values:
|
|
54
|
+
return self._values[key]
|
|
55
|
+
return self.parent.get(key) if self.parent is not None else None
|
|
56
|
+
|
|
57
|
+
def has(self, key: str) -> bool:
|
|
58
|
+
return key in self._values or (self.parent.has(key) if self.parent is not None else False)
|
|
59
|
+
|
|
60
|
+
def set(self, key: str, value: Any) -> None:
|
|
61
|
+
self._values[key] = value
|
|
62
|
+
|
|
63
|
+
def snapshot(self) -> dict[str, Any]:
|
|
64
|
+
"""All visible values; children shadow parents. Used as the template scope."""
|
|
65
|
+
inherited = self.parent.snapshot() if self.parent is not None else {}
|
|
66
|
+
return {**inherited, **self._values}
|
|
67
|
+
|
|
68
|
+
def child(self) -> Scope:
|
|
69
|
+
return Scope(self)
|
|
70
|
+
|
|
71
|
+
def add_resource(self, resource: Resource) -> None:
|
|
72
|
+
if self._disposed:
|
|
73
|
+
raise RuntimeError(f'Scope already disposed; cannot add "{resource.name}"')
|
|
74
|
+
self._resources.append(resource)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def resource_names(self) -> list[str]:
|
|
78
|
+
return [r.name for r in self._resources]
|
|
79
|
+
|
|
80
|
+
async def dispose(self) -> None:
|
|
81
|
+
"""Releases resources in reverse order. Every disposer runs; the first failure is raised."""
|
|
82
|
+
if self._disposed:
|
|
83
|
+
return
|
|
84
|
+
self._disposed = True
|
|
85
|
+
first: Exception | None = None
|
|
86
|
+
for resource in reversed(self._resources):
|
|
87
|
+
try:
|
|
88
|
+
result = resource.dispose()
|
|
89
|
+
if inspect.isawaitable(result):
|
|
90
|
+
await result
|
|
91
|
+
except Exception as error:
|
|
92
|
+
if first is None:
|
|
93
|
+
first = error
|
|
94
|
+
if first is not None:
|
|
95
|
+
raise first
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Logger(Protocol):
|
|
99
|
+
def __call__(self, level: str, message: str, data: dict[str, Any] | None = None) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _noop_log(level: str, message: str, data: dict[str, Any] | None = None) -> None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _noop_cost(usd: float) -> None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(kw_only=True)
|
|
111
|
+
class StepContext[Cfg]:
|
|
112
|
+
"""Everything an action can see."""
|
|
113
|
+
|
|
114
|
+
config: Cfg
|
|
115
|
+
run_id: str
|
|
116
|
+
item_id: str
|
|
117
|
+
#: The step's id in the pipeline tree.
|
|
118
|
+
node_id: str
|
|
119
|
+
attempt: int
|
|
120
|
+
#: Working directory for this step (project root or a workspace).
|
|
121
|
+
cwd: str
|
|
122
|
+
scope: Scope
|
|
123
|
+
registry: Registry
|
|
124
|
+
bus: EventBus
|
|
125
|
+
log: Logger
|
|
126
|
+
signal: AbortSignal
|
|
127
|
+
#: Adds spend (USD) to this step's recorded cost.
|
|
128
|
+
record_cost: Callable[[float], None]
|
|
129
|
+
#: Stable per-execution key, e.g. for idempotency: `<run>:<item>:<node>`.
|
|
130
|
+
step_key: str
|
|
131
|
+
#: The run's state store (human gates, comments and the effect journal live here).
|
|
132
|
+
store: StateStore | None = None
|
|
133
|
+
#: Epoch milliseconds; injectable so tests control time.
|
|
134
|
+
now: Callable[[], int] = field(default=lambda: int(time.time() * 1000))
|
|
135
|
+
#: `on_idle: watch`: gates wait for a decision instead of parking the item.
|
|
136
|
+
watch: bool = False
|
|
137
|
+
#: Parks created by this chain of resumed runs belong to this id.
|
|
138
|
+
lineage: str = ""
|
|
139
|
+
locks: Locks = field(default_factory=Locks)
|
|
140
|
+
#: Caps live agent sessions across the run; agents acquire it around their runner call.
|
|
141
|
+
agent_slots: asyncio.Semaphore | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RunChild(Protocol):
|
|
145
|
+
"""Runs a child step; `scope` overrides the scope it runs in (a per-pass scope for `map`)."""
|
|
146
|
+
|
|
147
|
+
def __call__(
|
|
148
|
+
self, node: PipelineNode, input: PortValues, scope: Scope | None = None, /
|
|
149
|
+
) -> Awaitable[Emit]: ...
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(kw_only=True)
|
|
153
|
+
class CompositeContext[Cfg](StepContext[Cfg]):
|
|
154
|
+
"""Everything a strategy can see. Children run only through `run`, so each is recorded."""
|
|
155
|
+
|
|
156
|
+
policy: OutcomePolicy
|
|
157
|
+
run: RunChild = field(init=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def create_step_context[Cfg](config: Cfg, **overrides: Any) -> StepContext[Cfg]:
|
|
161
|
+
"""Builds a context with sensible defaults; used by the scheduler and by tests."""
|
|
162
|
+
run_id: str = overrides.pop("run_id", "run")
|
|
163
|
+
item_id: str = overrides.pop("item_id", "item")
|
|
164
|
+
node_id: str = overrides.pop("node_id", "node")
|
|
165
|
+
values: dict[str, Any] = {
|
|
166
|
+
"attempt": 1,
|
|
167
|
+
"cwd": os.getcwd(),
|
|
168
|
+
"scope": Scope(),
|
|
169
|
+
"registry": Registry(),
|
|
170
|
+
"bus": EventBus(),
|
|
171
|
+
"log": _noop_log,
|
|
172
|
+
"record_cost": _noop_cost,
|
|
173
|
+
"signal": AbortSignal(),
|
|
174
|
+
"step_key": f"{run_id}:{item_id}:{node_id}",
|
|
175
|
+
}
|
|
176
|
+
values.update(overrides)
|
|
177
|
+
return StepContext(config=config, run_id=run_id, item_id=item_id, node_id=node_id, **values)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_UNITS = {"ms": 1, "s": 1000, "m": 60_000, "h": 3_600_000}
|
|
7
|
+
_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h)$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_duration(value: str | int | float) -> int:
|
|
11
|
+
"""Parses "90m", "30s", "1h", "250ms" (or a bare number of milliseconds) into milliseconds."""
|
|
12
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
13
|
+
if not math.isfinite(value) or value < 0:
|
|
14
|
+
raise ValueError(f"Invalid duration: {value}")
|
|
15
|
+
return int(value)
|
|
16
|
+
match = _PATTERN.match(str(value).strip())
|
|
17
|
+
if not match:
|
|
18
|
+
raise ValueError(f'Invalid duration "{value}" (use e.g. 30s, 90m, 1h)')
|
|
19
|
+
return round(float(match.group(1)) * _UNITS[match.group(2)])
|