Skip to content

Commit 99f2f94

Browse files
committed
Add science max-duration and final pushover status; bump v0.7.2
1 parent b183978 commit 99f2f94

6 files changed

Lines changed: 230 additions & 7 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,17 +160,23 @@ codexapi ralph --cancel --cwd /path/to/project
160160
Science mode wraps a short task in a science prompt and runs it through the
161161
Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`.
162162
Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts
163-
any improved figures of merit for optional notifications.
163+
any improved figures of merit for optional notifications. You can also set
164+
`--max-duration` to stop after the current iteration once a time limit is hit.
165+
The default science wrapper also tells the agent to create/use a local git
166+
branch when in a repo and make local commits for worthwhile improvements, while
167+
never committing or resetting `LOGBOOK.md` or `SCIENCE.md`.
164168

165169
```bash
166170
codexapi science "hyper-optimize the kernel cycles"
167171
codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3
172+
codexapi science "hyper-optimize the kernel cycles" --max-duration 90m
168173
```
169174

170175
Optional Pushover notifications: create `~/.pushover` with two non-empty lines.
171176
Line 1 is your user or group key, line 2 is the app API token. When this file
172177
exists, Science will send a notification whenever it detects a new best result,
173-
including the metric values and percent improvement. Task runs will also send a
178+
including the metric values and percent improvement, plus a final run-end status.
179+
Task runs will also send a
174180
✅/❌ notification with the task summary. Lead runs send a notification when the
175181
loop stops.
176182

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codexapi"
7-
version = "0.7.1"
7+
version = "0.7.2"
88
description = "Minimal Python API for running the Codex CLI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/codexapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@
2727
"task_result",
2828
"lead",
2929
]
30-
__version__ = "0.7.1"
30+
__version__ = "0.7.2"

src/codexapi/cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_SESSION_ID_RE = re.compile(
2525
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
2626
)
27+
_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)([smhdSMHD]?)\s*$")
2728
_TAIL_BYTES = 256 * 1024
2829
_TAIL_MAX_BYTES = 4 * 1024 * 1024
2930
_TAIL_MIN_LINES = 200
@@ -92,6 +93,25 @@ def _read_prompt(prompt):
9293
return data
9394

9495

96+
def _parse_duration_seconds(value, flag_name):
97+
if value is None:
98+
return 0.0
99+
text = str(value).strip()
100+
if not text:
101+
raise SystemExit(f"{flag_name} cannot be empty.")
102+
match = _DURATION_RE.match(text)
103+
if not match:
104+
raise SystemExit(
105+
f"{flag_name} must be a number with optional unit s/m/h/d (example: 90m)."
106+
)
107+
amount = float(match.group(1))
108+
unit = (match.group(2) or "m").lower()
109+
if amount < 0:
110+
raise SystemExit(f"{flag_name} must be >= 0.")
111+
multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
112+
return amount * multiplier
113+
114+
95115
def _read_prompt_file(path):
96116
if not path or not str(path).strip():
97117
raise SystemExit("Prompt file path is empty.")
@@ -1026,6 +1046,8 @@ def main(argv=None):
10261046
"Science mode (science command):\n"
10271047
" Wraps your short task in a science prompt and runs it via the Ralph loop.\n"
10281048
" Default uses --yolo. Use --no-yolo to run --full-auto instead.\n"
1049+
" Optional --max-duration stops before starting the next iteration once\n"
1050+
" the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n"
10291051
)
10301052
parser = argparse.ArgumentParser(
10311053
prog="codexapi",
@@ -1255,6 +1277,13 @@ def main(argv=None):
12551277
default=0,
12561278
help="Max iterations for the loop (0 means unlimited).",
12571279
)
1280+
science_parser.add_argument(
1281+
"--max-duration",
1282+
help=(
1283+
"Maximum loop runtime. Stops after the current iteration when reached. "
1284+
"Accepts s/m/h/d units (e.g. 90m, 2h, 45s); default unit is minutes."
1285+
),
1286+
)
12581287
science_parser.add_argument(
12591288
"--cancel",
12601289
action="store_true",
@@ -1440,6 +1469,8 @@ def main(argv=None):
14401469
)
14411470
if args.max_iterations != 0:
14421471
raise SystemExit("--max-iterations is not allowed with --cancel.")
1472+
if args.max_duration:
1473+
raise SystemExit("--max-duration is not allowed with --cancel.")
14431474
print(cancel_ralph_loop(args.cwd))
14441475
return
14451476
if args.ralph_fresh is None:
@@ -1579,6 +1610,7 @@ def main(argv=None):
15791610
if args.command == "science":
15801611
if args.max_iterations < 0:
15811612
raise SystemExit("--max-iterations must be >= 0.")
1613+
max_duration_seconds = _parse_duration_seconds(args.max_duration, "--max-duration")
15821614
Science(
15831615
prompt,
15841616
args.cwd,
@@ -1587,6 +1619,7 @@ def main(argv=None):
15871619
args.max_iterations,
15881620
args.completion_promise,
15891621
args.ralph_fresh,
1622+
max_duration_seconds,
15901623
)()
15911624
return
15921625
if args.command == "lead":

src/codexapi/science.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import os
55
import sys
6+
import time
67
from datetime import datetime, timezone
78

89
from .agent import agent
@@ -29,7 +30,10 @@
2930
"Try your best and have fun with this one! If you "
3031
"think of several options, pick one and run with it - I will not be available "
3132
"to make decisions for you, I give you my full permission to explore and make "
32-
"your own best judgement towards our goal! Remember to update SCIENCE.md. "
33+
"your own best judgement towards our goal! If you are in a git repository, "
34+
"create and use a local branch for this run. Make local commits for improvements "
35+
"worth keeping, but never commit or reset LOGBOOK.md or SCIENCE.md. "
36+
"Remember to update SCIENCE.md. "
3337
"Good hunting!"
3438
)
3539
_LOGBOOK_NAME = "LOGBOOK.md"
@@ -97,7 +101,10 @@ def __init__(
97101
max_iterations=0,
98102
completion_promise=None,
99103
fresh=True,
104+
max_duration_seconds=0,
100105
):
106+
if max_duration_seconds < 0:
107+
raise ValueError("max_duration_seconds must be >= 0")
101108
self._task = task.strip() if isinstance(task, str) else task
102109
prompt_a, prompt_b = _science_parts(task)
103110
prompt = f"{prompt_a}{prompt_b}"
@@ -117,11 +124,20 @@ def __init__(
117124
self._best_metrics = None
118125
self._run_title = None
119126
self._pushover = Pushover()
127+
self._pushover_enabled = False
128+
self._max_duration_seconds = float(max_duration_seconds)
129+
self._loop_started_monotonic = None
130+
self._duration_limit_hit = False
131+
self._last_iteration = 0
120132

121133
def hook_before_loop(self):
122134
super().hook_before_loop()
123-
self._pushover.ensure_ready()
124-
self._run_title = self._build_run_title()
135+
self._loop_started_monotonic = time.monotonic()
136+
self._pushover_enabled = self._pushover.ensure_ready()
137+
if self._pushover_enabled:
138+
self._run_title = self._build_run_title()
139+
else:
140+
self._run_title = _fallback_title(self._task)
125141

126142
def build_prompt(self, iteration):
127143
if iteration <= 1:
@@ -131,8 +147,33 @@ def build_prompt(self, iteration):
131147

132148
def hook_after_iteration(self, iteration, message):
133149
super().hook_after_iteration(iteration, message)
150+
self._last_iteration = iteration
134151
self._append_logbook(iteration, message)
135152
self._extract_and_notify(message)
153+
self._mark_duration_stop(iteration)
154+
155+
def hook_after_loop(self, last_message, stop_reason):
156+
super().hook_after_loop(last_message, stop_reason)
157+
if not self._pushover_enabled:
158+
return
159+
status = _format_final_status(
160+
stop_reason,
161+
self.max_iterations,
162+
self.completion_promise,
163+
self._duration_limit_hit,
164+
)
165+
lines = [
166+
f"Science run ended: {status}",
167+
f"Iterations completed: {self._last_iteration}",
168+
]
169+
if self._best_metrics:
170+
summary = _single_line(self._best_metrics.get("summary", "")).strip()
171+
metrics_text = _format_metrics(self._best_metrics.get("metrics") or [])
172+
if summary:
173+
lines.append(f"Best summary: {summary}")
174+
if metrics_text:
175+
lines.append(f"Best metrics: {metrics_text}")
176+
self._pushover.send(self._run_title, "\n".join(lines))
136177

137178
def hook_new_best(self, result):
138179
super().hook_new_best(result)
@@ -186,6 +227,25 @@ def _build_run_title(self):
186227
title = _fallback_title(self._task)
187228
return title
188229

230+
def _mark_duration_stop(self, iteration):
231+
if self._duration_limit_hit:
232+
return
233+
if self._max_duration_seconds <= 0:
234+
return
235+
if self._loop_started_monotonic is None:
236+
return
237+
elapsed = time.monotonic() - self._loop_started_monotonic
238+
if elapsed < self._max_duration_seconds:
239+
return
240+
self._duration_limit_hit = True
241+
self.max_iterations = (
242+
iteration if self.max_iterations == 0 else min(self.max_iterations, iteration)
243+
)
244+
print(
245+
"Science loop: Max duration reached; "
246+
"stopping after the current iteration."
247+
)
248+
189249

190250

191251
def _build_metrics_prompt(task, message, previous_best):
@@ -300,3 +360,30 @@ def _fallback_title(task):
300360

301361
def _warn(message):
302362
print(message, file=sys.stderr)
363+
364+
365+
def _format_final_status(
366+
stop_reason,
367+
max_iterations,
368+
completion_promise,
369+
duration_limit_hit,
370+
):
371+
if stop_reason == "max_iterations":
372+
if duration_limit_hit:
373+
return "max duration reached"
374+
return f"max iterations reached ({max_iterations})"
375+
if stop_reason == "promise":
376+
if completion_promise:
377+
return f"completion promise met ({completion_promise})"
378+
return "completion promise met"
379+
if stop_reason == "welfare_stop":
380+
return "agent requested welfare stop"
381+
if stop_reason == "canceled":
382+
return "loop canceled"
383+
if stop_reason == "interrupted":
384+
return "interrupted"
385+
if stop_reason == "error":
386+
return "stopped due to error"
387+
if stop_reason:
388+
return _single_line(stop_reason)
389+
return "finished"

tests/test_science.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import sys
2+
import tempfile
3+
import unittest
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
8+
9+
from codexapi.science import Science, _science_parts
10+
11+
12+
class _FakePushover:
13+
def __init__(self, enabled):
14+
self.enabled = enabled
15+
self.sent = []
16+
17+
def ensure_ready(self, announce=True):
18+
return self.enabled
19+
20+
def send(self, title, message):
21+
self.sent.append((title, message))
22+
return True
23+
24+
25+
class _TestScience(Science):
26+
def _append_logbook(self, iteration, message):
27+
return None
28+
29+
def _extract_and_notify(self, message):
30+
return None
31+
32+
def _build_run_title(self):
33+
return "test-run"
34+
35+
36+
class _FakeAgent:
37+
calls = 0
38+
39+
def __init__(
40+
self,
41+
cwd=None,
42+
yolo=True,
43+
thread_id=None,
44+
flags=None,
45+
welfare=False,
46+
include_thinking=False,
47+
):
48+
pass
49+
50+
def __call__(self, prompt):
51+
_FakeAgent.calls += 1
52+
return f"message {_FakeAgent.calls}"
53+
54+
55+
class ScienceTests(unittest.TestCase):
56+
def test_science_prompt_includes_git_commit_guidance(self):
57+
_prompt_a, prompt_b = _science_parts("improve performance")
58+
self.assertIn("create and use a local branch", prompt_b)
59+
self.assertIn("never commit or reset LOGBOOK.md or SCIENCE.md", prompt_b)
60+
61+
def test_max_duration_stops_after_current_iteration(self):
62+
_FakeAgent.calls = 0
63+
with tempfile.TemporaryDirectory() as tmpdir:
64+
runner = _TestScience(
65+
"improve performance",
66+
cwd=tmpdir,
67+
max_duration_seconds=60,
68+
)
69+
runner._pushover = _FakePushover(enabled=False)
70+
with patch("codexapi.ralph.Agent", _FakeAgent):
71+
with patch("codexapi.science.time.monotonic", side_effect=[0, 30, 61]):
72+
runner()
73+
self.assertEqual(_FakeAgent.calls, 2)
74+
self.assertTrue(runner._duration_limit_hit)
75+
self.assertEqual(runner._last_iteration, 2)
76+
77+
def test_final_pushover_update_sent_when_enabled(self):
78+
_FakeAgent.calls = 0
79+
with tempfile.TemporaryDirectory() as tmpdir:
80+
runner = _TestScience(
81+
"improve performance",
82+
cwd=tmpdir,
83+
max_iterations=1,
84+
)
85+
fake_pushover = _FakePushover(enabled=True)
86+
runner._pushover = fake_pushover
87+
with patch("codexapi.ralph.Agent", _FakeAgent):
88+
runner()
89+
self.assertEqual(len(fake_pushover.sent), 1)
90+
title, message = fake_pushover.sent[0]
91+
self.assertEqual(title, "test-run")
92+
self.assertIn("Science run ended: max iterations reached (1)", message)
93+
self.assertIn("Iterations completed: 1", message)
94+
95+
96+
if __name__ == "__main__":
97+
unittest.main()

0 commit comments

Comments
 (0)