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
|
@@ -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
|
+
)
|