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.
Files changed (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import os
6
+ import shlex
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from importlib.metadata import PackageNotFoundError, entry_points
10
+ from importlib.metadata import version as dist_version
11
+ from typing import Any
12
+
13
+ from whiletrue.basics import basics_plugin
14
+ from whiletrue.claude_code import (
15
+ HeadlessOptions,
16
+ InteractiveOptions,
17
+ PaneHost,
18
+ TmuxPaneHost,
19
+ claude_code_plugin,
20
+ )
21
+ from whiletrue.codex import CodexOptions, codex_plugin
22
+ from whiletrue.control import control_plugin
23
+ from whiletrue.core import Plugin, Registry
24
+ from whiletrue.git import git_plugin
25
+ from whiletrue.github import FileBackedGitHub, GitHubClient, github_plugin
26
+ from whiletrue.opencode import OpencodeOptions, opencode_plugin
27
+ from whiletrue.tmux import TmuxController, session_name
28
+
29
+ #: Third-party plugins advertise themselves under this entry-point group.
30
+ PLUGIN_ENTRY_POINT_GROUP = "whiletrue.plugins"
31
+
32
+
33
+ def whiletrue_version() -> str:
34
+ try:
35
+ return dist_version("whiletrue")
36
+ except PackageNotFoundError: # pragma: no cover (running from a bare checkout)
37
+ return "0.0.0"
38
+
39
+
40
+ #: Window 0 of the tmux session: the live control panel.
41
+ _CONTROL_PANEL = f"{shlex.quote(sys.executable)} -m whiletrue panel"
42
+
43
+
44
+ @dataclass
45
+ class RegistryOptions:
46
+ #: Path to the claude binary; defaults to WHILETRUE_CLAUDE_BIN or `claude`.
47
+ claude_command: str | None = None
48
+ #: The project directory: names the tmux session and is the control window's cwd.
49
+ project_dir: str | None = None
50
+ #: Where interactive agents run; defaults to panes in the project's tmux session.
51
+ pane_host: PaneHost | None = None
52
+ #: The GitHub client; defaults to the `gh` CLI.
53
+ github: GitHubClient | None = None
54
+
55
+
56
+ def create_default_registry(options: RegistryOptions | None = None) -> Registry:
57
+ """Core + the default plugins every project gets."""
58
+ opts = options or RegistryOptions()
59
+ registry = Registry()
60
+ version = whiletrue_version()
61
+ registry.use(control_plugin, version)
62
+ registry.use(basics_plugin, version)
63
+ registry.use(git_plugin, version)
64
+ fake_github = os.environ.get("WHILETRUE_FAKE_GITHUB")
65
+ github = opts.github or (FileBackedGitHub(fake_github) if fake_github else None)
66
+ registry.use(github_plugin(github), version)
67
+ command = opts.claude_command or os.environ.get("WHILETRUE_CLAUDE_BIN") or "claude"
68
+ project = opts.project_dir or os.getcwd()
69
+ host = opts.pane_host or TmuxPaneHost(
70
+ TmuxController(session_name(project), binary=os.environ.get("WHILETRUE_TMUX_BIN", "tmux")),
71
+ control_command=_CONTROL_PANEL,
72
+ cwd=project,
73
+ )
74
+ registry.use(
75
+ claude_code_plugin(
76
+ HeadlessOptions(command=command), InteractiveOptions(command=command, host=host)
77
+ ),
78
+ version,
79
+ )
80
+ registry.use(
81
+ opencode_plugin(
82
+ OpencodeOptions(
83
+ command=os.environ.get("WHILETRUE_OPENCODE_BIN") or "opencode", host=host
84
+ )
85
+ ),
86
+ version,
87
+ )
88
+ registry.use(
89
+ codex_plugin(
90
+ CodexOptions(command=os.environ.get("WHILETRUE_CODEX_BIN") or "codex", host=host)
91
+ ),
92
+ version,
93
+ )
94
+ return registry
95
+
96
+
97
+ def _as_plugin(candidate: Any) -> Plugin | None:
98
+ # An entry point may name a Plugin or a zero-argument factory returning one.
99
+ if callable(candidate) and not isinstance(candidate, Plugin):
100
+ candidate = candidate()
101
+ return candidate if isinstance(candidate, Plugin) else None
102
+
103
+
104
+ def _find_plugin(name: str, cwd: str) -> tuple[Plugin, str | None]:
105
+ for entry in entry_points(group=PLUGIN_ENTRY_POINT_GROUP):
106
+ if entry.name == name:
107
+ plugin = _as_plugin(entry.load())
108
+ if plugin is None:
109
+ break
110
+ return plugin, entry.dist.version if entry.dist is not None else None
111
+ else:
112
+ # Not an installed entry point: treat `name` as a module, importable from the project dir.
113
+ added = cwd not in sys.path
114
+ if added:
115
+ sys.path.insert(0, cwd)
116
+ try:
117
+ importlib.invalidate_caches()
118
+ if importlib.util.find_spec(name) is None:
119
+ raise LookupError(f'Cannot find plugin "{name}" (install it in this project)')
120
+ plugin = _as_plugin(getattr(importlib.import_module(name), "plugin", None))
121
+ if plugin is not None:
122
+ return plugin, None
123
+ except ModuleNotFoundError as error:
124
+ raise LookupError(
125
+ f'Cannot find plugin "{name}" (install it in this project)'
126
+ ) from error
127
+ finally:
128
+ if added:
129
+ sys.path.remove(cwd)
130
+ raise TypeError(f'Plugin "{name}" must export a Plugin as `plugin`')
131
+
132
+
133
+ def load_plugins(registry: Registry, uses: list[str], cwd: str) -> None:
134
+ """Installs plugins named in `uses:` that aren't already built in.
135
+
136
+ A name resolves to a `whiletrue.plugins` entry point, else to an importable module
137
+ (looked up from the project directory) that exposes a `plugin` attribute.
138
+ """
139
+ for name in uses:
140
+ if name in registry.plugins():
141
+ continue
142
+ plugin, version = _find_plugin(name, cwd)
143
+ registry.use(plugin, version)
@@ -0,0 +1,24 @@
1
+ RALPH_PIPELINE = """# whiletrue pipeline: a Ralph loop.
2
+ # Run the agent with a fresh context, check with a deterministic gate, repeat until green.
3
+ name: ralph
4
+
5
+ pipeline:
6
+ loop:
7
+ max: 5 # every loop is bounded; use `max: unbounded` to opt out explicitly
8
+ do:
9
+ - agent: { uses: ./agents/ralph.md }
10
+ - gate.shell: { run: "pytest" }
11
+ """
12
+
13
+ RALPH_AGENT = """---
14
+ agent: ralph
15
+ runner: claude-code-headless
16
+ permission_mode: acceptEdits
17
+ tools: [Read, Edit, Write, "Bash(pytest*)", "Bash(git *)"]
18
+ limits: { budget_usd: 2, turns: 40, wall: 20m }
19
+ ---
20
+ Work through the next unfinished item in TODO.md. Make the tests pass.
21
+
22
+ Failure output from the previous attempt (empty on the first try):
23
+ {{iter.previous}}
24
+ """
@@ -0,0 +1,14 @@
1
+ """The OpenAI Codex CLI runner."""
2
+
3
+ from whiletrue.codex.runner import CodexOptions, CodexRunner, build_codex_args
4
+ from whiletrue.core import PluginApi, SimplePlugin
5
+
6
+
7
+ def codex_plugin(options: CodexOptions | None = None) -> SimplePlugin:
8
+ def register(api: PluginApi) -> None:
9
+ api.register_runner(CodexRunner(options))
10
+
11
+ return SimplePlugin(name="whiletrue.codex", register=register)
12
+
13
+
14
+ __all__ = ["CodexOptions", "CodexRunner", "build_codex_args", "codex_plugin"]
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from whiletrue.basics.process import kill_process_group
11
+ from whiletrue.claude_code.panes import PaneHost, StreamMirror
12
+ from whiletrue.core import AgentRequest, AgentResult, RunContext, RunnerCapabilities
13
+ from whiletrue.opencode.runner import extract_json
14
+
15
+ _LINE_LIMIT = 64 * 1024 * 1024
16
+
17
+ #: whiletrue's permission modes (Claude Code's names) -> Codex sandbox modes.
18
+ SANDBOX_FOR_MODE = {
19
+ "plan": "read-only",
20
+ "default": "workspace-write",
21
+ "acceptEdits": "workspace-write",
22
+ "auto": "workspace-write",
23
+ "dontAsk": "workspace-write",
24
+ "bypassPermissions": "danger-full-access",
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class CodexOptions:
30
+ #: The codex binary. Defaults to `codex` on PATH.
31
+ command: str = "codex"
32
+ #: Where to show the live stream (a read-only pane); None runs without one.
33
+ host: PaneHost | None = None
34
+ extra_args: list[str] = field(default_factory=lambda: [])
35
+
36
+
37
+ def build_codex_args(
38
+ req: AgentRequest, *, schema_file: str | None = None, extra_args: list[str] | None = None
39
+ ) -> list[str]:
40
+ """argv after the binary. The prompt goes over stdin (`-`).
41
+
42
+ A fresh session uses real flags; resuming or forking uses `-c key=value` overrides, which
43
+ every `codex exec` subcommand accepts.
44
+ """
45
+ sandbox = SANDBOX_FOR_MODE.get(req.permission_mode or "default", "workspace-write")
46
+ if req.resume:
47
+ args = [
48
+ "exec",
49
+ "fork" if req.fork else "resume",
50
+ req.resume,
51
+ "--json",
52
+ "--skip-git-repo-check",
53
+ ]
54
+ args += ["-c", f'sandbox_mode="{sandbox}"']
55
+ if req.model:
56
+ args += ["-c", f'model="{req.model}"']
57
+ else:
58
+ args = ["exec", "--json", "--skip-git-repo-check", "-C", req.cwd, "-s", sandbox]
59
+ if req.model:
60
+ args += ["-m", req.model]
61
+ if schema_file:
62
+ args += ["--output-schema", schema_file]
63
+ return [*args, *(extra_args or []), "-"]
64
+
65
+
66
+ def _short(value: Any) -> str:
67
+ return json.dumps(value)[:300] if not isinstance(value, str) else value[:300]
68
+
69
+
70
+ class CodexRunner:
71
+ """Runs a step with `codex exec --json` (OpenAI Codex CLI).
72
+
73
+ Structured output uses Codex's own `--output-schema`. Codex reports tokens, not dollars, so
74
+ no cost is recorded and budgets cannot be enforced; wall time can.
75
+ """
76
+
77
+ id = "codex"
78
+ capabilities = RunnerCapabilities(
79
+ interactive=False, structured_output=True, resume=True, fork=True, budget=False
80
+ )
81
+
82
+ def __init__(self, options: CodexOptions | None = None) -> None:
83
+ self._options = options or CodexOptions()
84
+
85
+ async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult:
86
+ if ctx.signal.aborted:
87
+ return AgentResult(ok=False, text="", error="aborted")
88
+ mirror = StreamMirror(self._options.host)
89
+ await mirror.open(request.cwd, request.step_key, request.label or "codex")
90
+ try:
91
+ return await self._run(request, ctx, mirror)
92
+ finally:
93
+ await mirror.close(keep=request.on_done == "keep", title=f"{request.label} (done)")
94
+
95
+ async def _run(
96
+ self, request: AgentRequest, ctx: RunContext, mirror: StreamMirror
97
+ ) -> AgentResult:
98
+ opts = self._options
99
+ schema_file: str | None = None
100
+ if request.output_schema and not request.resume:
101
+ directory = Path(request.cwd) / ".whiletrue" / "sessions"
102
+ directory.mkdir(parents=True, exist_ok=True)
103
+ path = directory / f"codex-schema-{uuid.uuid4().hex[:8]}.json"
104
+ path.write_text(json.dumps(_strict(request.output_schema)), encoding="utf-8")
105
+ schema_file = str(path)
106
+ try:
107
+ proc = await asyncio.create_subprocess_exec(
108
+ opts.command,
109
+ *build_codex_args(request, schema_file=schema_file, extra_args=opts.extra_args),
110
+ cwd=request.cwd,
111
+ stdin=asyncio.subprocess.PIPE,
112
+ stdout=asyncio.subprocess.PIPE,
113
+ stderr=asyncio.subprocess.DEVNULL,
114
+ start_new_session=True,
115
+ limit=_LINE_LIMIT,
116
+ )
117
+ except OSError as error:
118
+ return AgentResult(ok=False, text="", error=f"Failed to start {opts.command}: {error}")
119
+
120
+ messages: list[str] = []
121
+ state: dict[str, Any] = {"thread": None, "turns": 0, "error": None, "failed": False}
122
+
123
+ def emit(event: dict[str, Any]) -> None:
124
+ if ctx.on_event is not None:
125
+ ctx.on_event(event)
126
+
127
+ async def drive() -> None:
128
+ assert proc.stdin is not None
129
+ assert proc.stdout is not None
130
+ proc.stdin.write(request.prompt.encode())
131
+ await proc.stdin.drain()
132
+ proc.stdin.close()
133
+ while raw := await proc.stdout.readline():
134
+ try:
135
+ event = json.loads(raw)
136
+ except ValueError:
137
+ continue
138
+ if not isinstance(event, dict):
139
+ continue
140
+ fields = cast(dict[str, Any], event)
141
+ kind = fields.get("type")
142
+ if kind == "thread.started":
143
+ state["thread"] = fields.get("thread_id")
144
+ elif kind == "turn.completed":
145
+ state["turns"] += 1
146
+ elif kind == "turn.failed":
147
+ state["failed"] = True
148
+ err = cast(dict[str, Any], fields.get("error") or {})
149
+ state["error"] = str(err.get("message", "turn failed"))
150
+ mirror.note(f"[error] {state['error']}")
151
+ elif kind == "error":
152
+ # Codex retries on its own and reports each attempt; the last one counts.
153
+ state["error"] = str(fields.get("message", ""))
154
+ elif kind in ("item.completed", "item.started"):
155
+ item = cast(dict[str, Any], fields.get("item") or {})
156
+ if kind == "item.completed":
157
+ self_note(item, messages, mirror, emit)
158
+
159
+ drive_task = asyncio.ensure_future(drive())
160
+ abort_task = asyncio.ensure_future(ctx.signal.wait())
161
+ wall_ms = request.limits.wall_ms if request.limits is not None else None
162
+ try:
163
+ done, _ = await asyncio.wait(
164
+ {drive_task, abort_task},
165
+ timeout=None if wall_ms is None else wall_ms / 1000,
166
+ return_when=asyncio.FIRST_COMPLETED,
167
+ )
168
+ if drive_task not in done:
169
+ kill_process_group(proc.pid)
170
+ drive_task.cancel()
171
+ await asyncio.gather(drive_task, return_exceptions=True)
172
+ await proc.wait()
173
+ return AgentResult(
174
+ ok=False,
175
+ text="\n".join(messages),
176
+ error="aborted" if abort_task in done else "wall-time limit exceeded",
177
+ )
178
+ code = await proc.wait()
179
+ finally:
180
+ abort_task.cancel()
181
+ text = messages[-1].strip() if messages else ""
182
+ common: dict[str, Any] = {
183
+ "text": text,
184
+ "session_id": state["thread"],
185
+ "turns": state["turns"] or None,
186
+ }
187
+ if code != 0 or state["failed"]:
188
+ detail = state["error"] or "no details"
189
+ return AgentResult(ok=False, error=f"codex failed: {detail}", **common)
190
+ if request.output_schema:
191
+ output = extract_json(text)
192
+ if output is None:
193
+ return AgentResult(ok=False, error="the reply contained no JSON value", **common)
194
+ return AgentResult(ok=True, output=output, **common)
195
+ return AgentResult(ok=True, **common)
196
+
197
+
198
+ def self_note(
199
+ item: dict[str, Any],
200
+ messages: list[str],
201
+ mirror: StreamMirror,
202
+ emit: Any,
203
+ ) -> None:
204
+ """Records what an `item.completed` event says: a message, a command, a file change, a tool."""
205
+ kind = item.get("type")
206
+ if kind == "agent_message":
207
+ text = str(item.get("text", ""))
208
+ messages.append(text)
209
+ mirror.note(text)
210
+ emit({"kind": "text"})
211
+ elif kind == "command_execution":
212
+ command = _short(item.get("command", ""))
213
+ mirror.note(f"$ {command}")
214
+ emit({"kind": "tool_use", "name": "shell", "command": command})
215
+ elif kind == "file_change":
216
+ mirror.note(f"[file change] {_short(item.get('changes', ''))}")
217
+ emit({"kind": "tool_use", "name": "apply_patch"})
218
+ elif kind in ("mcp_tool_call", "web_search"):
219
+ name = str(item.get("tool") or item.get("query") or kind)
220
+ mirror.note(f"[{kind}] {name}")
221
+ emit({"kind": "tool_use", "name": name})
222
+
223
+
224
+ def _strict(schema: dict[str, Any]) -> dict[str, Any]:
225
+ """Codex's structured output wants closed objects (`additionalProperties: false`)."""
226
+ out = json.loads(json.dumps(schema))
227
+
228
+ def close(node: Any) -> None:
229
+ if isinstance(node, dict):
230
+ fields = cast(dict[str, Any], node)
231
+ if fields.get("type") == "object":
232
+ fields.setdefault("additionalProperties", False)
233
+ props = fields.get("properties")
234
+ if isinstance(props, dict):
235
+ fields["required"] = list(cast(dict[str, Any], props))
236
+ for value in fields.values():
237
+ close(value)
238
+ elif isinstance(node, list):
239
+ for value in cast(list[Any], node):
240
+ close(value)
241
+
242
+ close(out)
243
+ return cast(dict[str, Any], out)
@@ -0,0 +1,38 @@
1
+ """Control-flow strategies: sequence, tap, loop, map, parallel, switch, retry, try."""
2
+
3
+ from whiletrue.control.chain import run_chain
4
+ from whiletrue.control.loop import LoopConfig, loop_strategy
5
+ from whiletrue.control.map_ import map_strategy
6
+ from whiletrue.control.parallel import parallel_strategy
7
+ from whiletrue.control.retry import retry_strategy, try_strategy
8
+ from whiletrue.control.sequence import sequence_strategy, tap_strategy
9
+ from whiletrue.control.switch import switch_strategy
10
+ from whiletrue.core import PluginApi, SimplePlugin
11
+
12
+
13
+ def _register(api: PluginApi) -> None:
14
+ api.register_strategy(sequence_strategy)
15
+ api.register_strategy(loop_strategy)
16
+ api.register_strategy(map_strategy)
17
+ api.register_strategy(parallel_strategy)
18
+ api.register_strategy(switch_strategy)
19
+ api.register_strategy(retry_strategy)
20
+ api.register_strategy(try_strategy)
21
+ api.register_strategy(tap_strategy)
22
+
23
+
24
+ control_plugin = SimplePlugin(name="whiletrue.control", register=_register)
25
+
26
+ __all__ = [
27
+ "LoopConfig",
28
+ "control_plugin",
29
+ "loop_strategy",
30
+ "map_strategy",
31
+ "parallel_strategy",
32
+ "retry_strategy",
33
+ "run_chain",
34
+ "sequence_strategy",
35
+ "switch_strategy",
36
+ "tap_strategy",
37
+ "try_strategy",
38
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from whiletrue.core import CompositeContext, Emit, PipelineNode, Scope, resolve_outcome
6
+ from whiletrue.core.module import PortValues
7
+
8
+ #: Wires can form cycles (a fixer wired back to its checker); this bounds them.
9
+ MAX_ROUTES = 1000
10
+
11
+
12
+ async def run_chain(
13
+ children: list[PipelineNode],
14
+ input: PortValues,
15
+ ctx: CompositeContext[Any],
16
+ scope: Scope | None = None,
17
+ ) -> Emit:
18
+ """Runs children in order, feeding each child's output into the next child's input.
19
+
20
+ A port wired to a sibling (`wires: {fail: fix}`) routes there instead, carrying the emitted
21
+ value, and the chain continues from that sibling. A sibling wired to from an earlier step is a
22
+ branch: it only runs when routed to. Any other outcome that doesn't continue
23
+ (fail, needs_human, rejected, ...) stops the chain and is returned for the parent to handle.
24
+ """
25
+ index_of = {child.id: i for i, child in enumerate(children)}
26
+ #: Steps an earlier sibling's wire points forward at are branches: reached by a route, never
27
+ #: by falling through. (A wire pointing back at an earlier step is a loop, not a branch.)
28
+ branches = {
29
+ target
30
+ for i, child in enumerate(children)
31
+ for target in child.wires.values()
32
+ if index_of.get(target, -1) > i
33
+ }
34
+ current = Emit("ok", input.get("in"))
35
+ index = 0
36
+ routes = 0
37
+ while index < len(children):
38
+ if ctx.signal.aborted:
39
+ return Emit("fail", {"error": "cancelled"})
40
+ child = children[index]
41
+ emit = await ctx.run(child, {} if current.value is None else {"in": current.value}, scope)
42
+ resolution = resolve_outcome(emit.port, child.wires, ctx.policy)
43
+ if resolution.action == "route":
44
+ routes += 1
45
+ if routes > MAX_ROUTES:
46
+ return Emit("fail", {"error": f"wires routed more than {MAX_ROUTES} times"})
47
+ target = index_of.get(resolution.target or "")
48
+ if target is None:
49
+ return Emit("fail", {"error": f'Wire targets unknown step "{resolution.target}"'})
50
+ current = emit
51
+ index = target
52
+ continue
53
+ if resolution.action != "continue":
54
+ return emit
55
+ # `skip` passes the previous value through untouched.
56
+ if emit.port != "skip":
57
+ current = emit
58
+ index += 1
59
+ while index < len(children) and children[index].id in branches:
60
+ index += 1
61
+ return current
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, PositiveInt
8
+
9
+ from whiletrue.control.chain import run_chain
10
+ from whiletrue.core import (
11
+ CompositeContext,
12
+ ConfigIssue,
13
+ Emit,
14
+ PipelineNode,
15
+ Strategy,
16
+ ValidateContext,
17
+ define_strategy,
18
+ parse_expr,
19
+ )
20
+ from whiletrue.core.expr import ExprError
21
+ from whiletrue.core.module import PortValues
22
+
23
+ #: Variables a loop expression may use.
24
+ LOOP_VARIABLES = frozenset({"run", "item", "iter", "last", "input"})
25
+
26
+
27
+ class LoopConfig(BaseModel):
28
+ max: PositiveInt | Literal["unbounded"] | None = None
29
+ #: CEL, evaluated after each iteration; the loop ends successfully when it is true.
30
+ until: str | None = None
31
+ #: CEL list to iterate; each iteration sees `iter.value`. Bounded by the list length.
32
+ over: str | None = None
33
+ #: Return the list of every successful iteration's output instead of just the last.
34
+ collect: bool = False
35
+ #: `ok`: stop as soon as an iteration succeeds (Ralph). `never`: always run all iterations.
36
+ #: Ignored when `until` or `over` is set.
37
+ stop_when: Literal["ok", "never"] = "ok"
38
+
39
+
40
+ def describe(value: Any) -> str:
41
+ if isinstance(value, str):
42
+ return value
43
+ if isinstance(value, dict):
44
+ parts = [v for k in ("error", "output") if isinstance(v := value.get(k), str)] # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
45
+ if parts:
46
+ return "\n".join(parts)
47
+ if value is None:
48
+ return ""
49
+ return json.dumps(value, separators=(",", ":"), default=str)
50
+
51
+
52
+ def _validate(config: LoopConfig, _vctx: ValidateContext) -> list[ConfigIssue]:
53
+ issues: list[ConfigIssue] = []
54
+ if config.max is None and config.over is None:
55
+ issues.append(
56
+ ConfigIssue(
57
+ "error",
58
+ "`loop` requires `max` (or an explicit `max: unbounded`) or an `over` list: "
59
+ "loops are bounded by default",
60
+ )
61
+ )
62
+ for name, source in (("until", config.until), ("over", config.over)):
63
+ if source is None:
64
+ continue
65
+ try:
66
+ problems = parse_expr(source).check(LOOP_VARIABLES)
67
+ except ExprError as error:
68
+ problems = [str(error)]
69
+ issues.extend(ConfigIssue("error", f"`loop.{name}`: {p}") for p in problems)
70
+ return issues
71
+
72
+
73
+ def _context(ctx: CompositeContext[Any], input: PortValues, last: Emit | None) -> dict[str, Any]:
74
+ values = {**ctx.scope.snapshot(), "input": input.get("in")}
75
+ values["last"] = (
76
+ {"port": last.port, "ok": last.port == "ok", "value": last.value} if last else None
77
+ )
78
+ return values
79
+
80
+
81
+ async def _run(
82
+ children: list[PipelineNode], input: PortValues, ctx: CompositeContext[LoopConfig]
83
+ ) -> Emit:
84
+ cfg = ctx.config
85
+ until = parse_expr(cfg.until) if cfg.until else None
86
+ items: list[Any] | None = None
87
+ if cfg.over is not None:
88
+ value = parse_expr(cfg.over).eval(_context(ctx, input, None))
89
+ if not isinstance(value, list):
90
+ return Emit(
91
+ "fail", {"error": f"`loop.over` must be a list, got {type(value).__name__}"}
92
+ )
93
+ items = list(value) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType]
94
+ limit = math.inf if cfg.max is None or cfg.max == "unbounded" else cfg.max
95
+ if items is not None:
96
+ limit = min(limit, len(items))
97
+
98
+ last = Emit("ok")
99
+ collected: list[Any] = []
100
+ index = 0
101
+ while index < limit:
102
+ index += 1
103
+ if ctx.signal.aborted:
104
+ return Emit("fail", {"error": "cancelled"})
105
+ ctx.scope.set(
106
+ "iter",
107
+ {
108
+ "index": index,
109
+ "previous": describe("" if last.port == "ok" else last.value),
110
+ "value": items[index - 1] if items is not None else None,
111
+ },
112
+ )
113
+ ctx.log("info", f"loop iteration {index}{'' if limit == math.inf else f'/{limit}'}", None)
114
+ last = await run_chain(children, input, ctx)
115
+ if last.port in ("needs_human", "rejected"):
116
+ return last
117
+ if last.port == "ok" and cfg.collect:
118
+ collected.append(last.value)
119
+ result = Emit("ok", collected if cfg.collect else last.value)
120
+ if until is not None:
121
+ if until.eval_bool(_context(ctx, input, last)):
122
+ return result
123
+ elif items is not None:
124
+ if last.port != "ok":
125
+ return last
126
+ elif cfg.stop_when == "ok" and last.port == "ok":
127
+ return result
128
+ if until is None and (items is not None or cfg.stop_when == "never"):
129
+ return Emit("ok", collected if cfg.collect else last.value)
130
+ return Emit("fail", {"error": f"loop gave up after {limit} iterations", "last": last.value})
131
+
132
+
133
+ #: Repeats its children, bounded by `max` (or an explicit `max: unbounded`) or by an `over` list.
134
+ #: Each iteration sees `{{iter.index}}` (1-based), `{{iter.previous}}` (the previous failed
135
+ #: iteration's output) and `{{iter.value}}` (the current `over` element). `until` is a CEL
136
+ #: condition over `last` (`last.ok`, `last.value`), `iter`, `input`, `item`, `run`.
137
+ loop_strategy: Strategy[Any] = define_strategy(
138
+ Strategy(id="loop", config=LoopConfig, validate=_validate, run=_run)
139
+ )