From 32db30bc6a55fb1a8a8c0e0ac3aea0d5783750a7 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 9 Jan 2026 08:20:35 +0100 Subject: [PATCH 01/78] Bump version to 0.1.9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7bceeb2..a83fae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.1.8" +version = "0.1.9" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" From 20bd565ed1094ba3a114c020ee9a8c8a227b3270 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 9 Jan 2026 09:31:15 +0100 Subject: [PATCH 02/78] Release 0.2.0 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a83fae1..d986755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.1.9" +version = "0.2.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 242a033..6982dc3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.1.8" +__version__ = "0.2.0" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index dd66ff0..28da498 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -87,9 +87,12 @@ def _parse_timestamp(value): if value.endswith("Z"): value = value[:-1] + "+00:00" try: - return datetime.fromisoformat(value) + parsed = datetime.fromisoformat(value) except ValueError: return None + if parsed.tzinfo is None: + return parsed + return parsed.astimezone().replace(tzinfo=None) def _tail_lines(path): From 6227127a8fd23d522cbf344a1c054e9343e2fd1f Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 9 Jan 2026 11:04:29 +0100 Subject: [PATCH 03/78] Release 0.3.0 --- README.md | 22 +++-- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 180 +++++++++++++++++++++------------------ src/codexapi/ralph.py | 4 +- 5 files changed, 116 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index b93e4ae..9497228 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,16 @@ print(result.success, result.summary) After installing, use the `codexapi` command: ```bash -codexapi "Summarize this repo." -codexapi --cwd /path/to/project "Fix the failing tests." -echo "Say hello." | codexapi +codexapi run "Summarize this repo." +codexapi run --cwd /path/to/project "Fix the failing tests." +echo "Say hello." | codexapi run ``` -Task mode exits with code 0 on success and 1 on failure, printing the summary. +`codexapi task` exits with code 0 on success and 1 on failure, printing the summary. + +```bash +codexapi task "Fix the failing tests." --max-iterations 5 +``` Show running sessions and their latest activity: @@ -66,17 +70,17 @@ Press `h` for keys. Resume a session and print the thread id to stderr: ```bash -codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off." +codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting -`.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`. +`.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. ```bash -codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5 -codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3 -codexapi --ralph-cancel --cwd /path/to/project +codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5 +codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3 +codexapi ralph --cancel --cwd /path/to/project ``` ## API diff --git a/pyproject.toml b/pyproject.toml index d986755..35fe733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.2.0" +version = "0.3.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 6982dc3..2c029f2 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 28da498..7c728ca 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -870,146 +870,164 @@ def _run_top(argv): def main(argv=None): argv = sys.argv[1:] if argv is None else list(argv) - if argv and argv[0] == "top": - _run_top(argv[1:]) - return ralph_help = ( - "Ralph loop mode (--ralph):\n" + "Ralph loop mode (ralph command):\n" " Repeats the exact same prompt each iteration until a completion promise\n" " is detected or --max-iterations is reached (0 means unlimited).\n" " Completion promise: output TEXT where TEXT matches\n" " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n" " Only output the promise when it is completely and unequivocally TRUE.\n" - " Cancel by deleting .codexapi/ralph-loop.local.md or running --ralph-cancel.\n" + " Cancel by deleting .codexapi/ralph-loop.local.md or running codexapi ralph --cancel.\n" " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n" " each iteration (no shared context).\n" ) parser = argparse.ArgumentParser( prog="codexapi", description="Run Codex via the codexapi wrapper.", - epilog=ralph_help, - formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument( + subparsers = parser.add_subparsers(dest="command") + + run_parser = subparsers.add_parser( + "run", + help="Run a Codex prompt.", + ) + run_parser.add_argument( "prompt", nargs="?", help="Prompt to send. Use '-' or omit to read from stdin.", ) - parser.add_argument( - "--task", - action="store_true", - help="Run in task mode with verification retries.", - ) - parser.add_argument( - "--check", - help="Optional check prompt for --task. Defaults to the task prompt.", - ) - parser.add_argument("--cwd", help="Working directory for the Codex session.") - parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") - parser.add_argument( + run_parser.add_argument("--cwd", help="Working directory for the Codex session.") + run_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + run_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) - parser.add_argument( + run_parser.add_argument( "--thread-id", help="Resume an existing Codex thread id.", ) - parser.add_argument( + run_parser.add_argument( "--print-thread-id", action="store_true", help="Print the current thread id to stderr after running.", ) - parser.add_argument( - "--ralph", - action="store_true", - help="Run a Ralph loop that repeats the same prompt each iteration.", + + task_parser = subparsers.add_parser( + "task", + help="Run a task with verification retries.", + ) + task_parser.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + task_parser.add_argument( + "--check", + help="Optional check prompt. Defaults to the task prompt.", + ) + task_parser.add_argument( + "--max-iterations", + type=int, + default=10, + help="Max verification retries after a failed check (0 means no retries).", + ) + task_parser.add_argument("--cwd", help="Working directory for the Codex session.") + task_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + task_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to Codex (quoted as needed).", + ) + + ralph_parser = subparsers.add_parser( + "ralph", + help="Run a Ralph loop.", + epilog=ralph_help, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ralph_parser.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", ) - parser.add_argument( + ralph_parser.add_argument( "--max-iterations", type=int, - default=None, - help="Max iterations for --ralph (0 means unlimited).", + default=0, + help="Max iterations for the loop (0 means unlimited).", + ) + ralph_parser.add_argument( + "--cancel", + action="store_true", + help="Cancel the Ralph loop state in the target cwd.", ) - parser.add_argument( + ralph_parser.add_argument( "--completion-promise", - help="Promise text for --ralph to match in ....", + help="Promise text to match in ....", ) - parser.add_argument( + ralph_parser.add_argument( "--ralph-fresh", action="store_true", - help="With --ralph, start each iteration with a fresh Agent context.", + help="Start each iteration with a fresh Agent context.", ) - parser.add_argument( - "--ralph-cancel", - action="store_true", - help=( - "Cancel a Ralph loop by removing .codexapi/ralph-loop.local.md " - "(respects --cwd)." - ), + ralph_parser.add_argument("--cwd", help="Working directory for the Codex session.") + ralph_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + ralph_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to Codex (quoted as needed).", + ) + + subparsers.add_parser( + "top", + help="Show running Codex sessions.", ) args = parser.parse_args(argv) - if args.ralph_cancel: - if ( - args.ralph - or args.task - or args.thread_id - or args.print_thread_id - or args.max_iterations is not None - or args.completion_promise is not None - or args.ralph_fresh - or args.check is not None - or args.prompt - ): - raise SystemExit( - "--ralph-cancel cannot be combined with prompts or other modes." - ) - print(cancel_ralph_loop(args.cwd)) + if args.command is None: + parser.print_help() + raise SystemExit(2) + if args.command == "top": + _run_top([]) return - if args.check is not None and not args.task: - raise SystemExit("--check requires --task.") - if not args.ralph and ( - args.max_iterations is not None - or args.completion_promise is not None - or args.ralph_fresh - ): - raise SystemExit( - "--max-iterations/--completion-promise/--ralph-fresh require --ralph." - ) - if args.ralph and (args.task or args.thread_id or args.print_thread_id): - raise SystemExit( - "--task/--thread-id/--print-thread-id are not supported with --ralph." - ) - if args.task and (args.thread_id or args.print_thread_id): - raise SystemExit("--thread-id/--print-thread-id are not supported with --task.") - prompt = _read_prompt(args.prompt) + if args.command == "ralph": + if args.cancel: + if args.prompt: + raise SystemExit("ralph --cancel takes no prompt.") + if args.completion_promise or args.ralph_fresh: + raise SystemExit("--completion-promise/--ralph-fresh are not allowed with --cancel.") + if args.max_iterations != 0: + raise SystemExit("--max-iterations is not allowed with --cancel.") + print(cancel_ralph_loop(args.cwd)) + return + prompt = _read_prompt(args.prompt) exit_code = 0 - if args.ralph: - max_iterations = args.max_iterations if args.max_iterations is not None else 0 - if max_iterations < 0: + if args.command == "ralph": + if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") run_ralph_loop( prompt, args.cwd, args.yolo, args.flags, - max_iterations, + args.max_iterations, args.completion_promise, args.ralph_fresh, ) return - if args.task: + if args.command == "task": + if args.max_iterations < 0: + raise SystemExit("--max-iterations must be >= 0.") check = args.check if args.check is not None else prompt try: message = task( prompt, check, - cwd=args.cwd, - yolo=args.yolo, - flags=args.flags, + args.max_iterations, + args.cwd, + args.yolo, + args.flags, ) except TaskFailed as exc: message = exc.summary diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 02b8ea5..4a0e4be 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -39,7 +39,7 @@ def run_ralph_loop( By default a single Agent instance is reused for shared context. Set `fresh=True` to create a new Agent each iteration for a clean context. - Cancel by deleting the state file or running `codexapi --ralph-cancel`. + Cancel by deleting the state file or running `codexapi ralph --cancel`. """ if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -81,7 +81,7 @@ def run_ralph_loop( "", "The loop will resend the SAME PROMPT each iteration.", "Cancel by deleting .codexapi/ralph-loop.local.md or running", - "codexapi --ralph-cancel.", + "codexapi ralph --cancel.", "No manual stop beyond max iterations or completion promise.", "", "To monitor: head -10 .codexapi/ralph-loop.local.md", From 7dd7dbbe54cf74eacb700d1bc701c5a0d80d3201 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 9 Jan 2026 14:54:56 +0100 Subject: [PATCH 04/78] Release 0.3.1 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 23 +++++++++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35fe733..5226f2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.3.0" +version = "0.3.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 2c029f2..d5d5344 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 7c728ca..0a5fa52 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -224,19 +224,20 @@ def _is_session_file(path, root_str): def _list_codex_processes(): result = subprocess.run( - ["ps", "-ax", "-o", "pid=,ppid=,comm=,args="], + ["ps", "-ax", "-o", "pid=,ppid=,uid=,comm=,args="], capture_output=True, text=True, ) if result.returncode != 0: return [] + current_uid = os.getuid() processes = [] for line in result.stdout.splitlines(): line = line.strip() if not line: continue - parts = line.split(None, 3) - if len(parts) < 3: + parts = line.split(None, 4) + if len(parts) < 4: continue try: pid = int(parts[0]) @@ -246,8 +247,14 @@ def _list_codex_processes(): ppid = int(parts[1]) except ValueError: continue - comm = parts[2] - args = parts[3] if len(parts) > 3 else "" + try: + uid = int(parts[2]) + except ValueError: + continue + if uid != current_uid: + continue + comm = parts[3] + args = parts[4] if len(parts) > 4 else "" if comm == "codex" or re.search(r"(^|[\\s/])codex(\\s|$)", args): processes.append({"pid": pid, "ppid": ppid, "comm": comm, "args": args}) return processes @@ -273,7 +280,11 @@ def _process_session_files(pid, root): proc_fd = Path(f"/proc/{pid}/fd") if proc_fd.exists(): - for entry in proc_fd.iterdir(): + try: + entries = list(proc_fd.iterdir()) + except OSError: + return paths + for entry in entries: try: target = os.readlink(entry) except OSError: From d065444921d34645a98cdf5056fa5a2285f4ac54 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 10 Jan 2026 11:24:58 +0100 Subject: [PATCH 05/78] Update verifier prompt and bump version --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/task.py | 21 ++++++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5226f2d..39d0aab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.3.1" +version = "0.3.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index d5d5344..ac54a79 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/src/codexapi/task.py b/src/codexapi/task.py index cac8d7f..12b6fb0 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -3,12 +3,14 @@ import json import logging -from .agent import Agent +from .agent import Agent, agent _logger = logging.getLogger(__name__) _CHECK_PREFIX = ( - "You are a verification agent. Evaluate the workspace against the check below.\n" + "You are a verification agent. Explore this workspace and carefully evaluate it " + "against the check below. Collect evidence by running any tests and/or reading " + "and tracing through code, but do not change any of the code.\n" "Return only JSON with keys: success (boolean) and reason (string).\n" "Set success to true only if everything matches the intent." ) @@ -18,7 +20,10 @@ def _default_check(prompt): return ( "Verify that the task below has been completed in line with the original intent.\n" - f"Task:\n{prompt}" + "Task:\n" + "```\n" + f"{prompt}\n" + "```" ) @@ -116,7 +121,11 @@ def task_result( yolo=False, flags=None, ): - """Run a prompt with optional checker-driven retries and return TaskResult.""" + """Run a prompt with optional checker-driven retries and return TaskResult. + + The runner keeps a single session. Each verification attempt uses a fresh, + stateless agent call. + """ if check is False: runner = Agent(cwd, yolo, None, flags) summary = runner(prompt) @@ -129,13 +138,11 @@ def task_result( raise ValueError("n must be >= 0") runner = Agent(cwd, yolo, None, flags) - checker = Agent(cwd, yolo, None, flags) - runner(prompt) check_prompt = _build_check_prompt(check) for attempt in range(n + 1): - success, reason = _check_result(checker(check_prompt)) + success, reason = _check_result(agent(check_prompt, cwd, yolo, flags)) if success: summary = runner(_success_prompt()) return TaskResult( From b4bf4dba67297ebea53fa6ad4ada3ed3c2511aa7 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 10 Jan 2026 11:41:12 +0100 Subject: [PATCH 06/78] Add progress summaries for task runs --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 6 ++ src/codexapi/task.py | 127 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 39d0aab..22d3c6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.3.2" +version = "0.3.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index ac54a79..0807769 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 0a5fa52..cad39ce 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -948,6 +948,11 @@ def main(argv=None): "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + task_parser.add_argument( + "--progress", + action="store_true", + help="Print progress after each verification round.", + ) ralph_parser = subparsers.add_parser( "ralph", @@ -1039,6 +1044,7 @@ def main(argv=None): args.cwd, args.yolo, args.flags, + args.progress, ) except TaskFailed as exc: message = exc.summary diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 12b6fb0..18e61f1 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -2,6 +2,7 @@ import json import logging +import time from .agent import Agent, agent @@ -15,6 +16,12 @@ "Set success to true only if everything matches the intent." ) _CHECK_SUFFIX = "JSON only. No markdown or extra text." +_PROGRESS_PROMPT = ( + "Summarize the outputs below in one line each.\n" + "Return only JSON with keys: agent (string) and check (string).\n" + "Each value must be a single line with no newlines.\n" + "Do not run commands or change any files." +) def _default_check(prompt): @@ -31,6 +38,16 @@ def _build_check_prompt(check): return f"{_CHECK_PREFIX}\n\n{check}\n\n{_CHECK_SUFFIX}" +def _build_progress_prompt(agent_output, check_output): + return ( + f"{_PROGRESS_PROMPT}\n\n" + "AGENT OUTPUT:\n" + f"{agent_output}\n\n" + "CHECK OUTPUT:\n" + f"{check_output}" + ) + + def _check_result(output): try: data = json.loads(output) @@ -50,6 +67,78 @@ def _check_result(output): return success, reason.strip() +def _progress_result(output): + try: + data = json.loads(output) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Progress summary returned invalid JSON: {exc}" + ) from exc + + if not isinstance(data, dict): + raise RuntimeError("Progress summary JSON must be an object.") + + agent_summary = data.get("agent") + check_summary = data.get("check") + if not isinstance(agent_summary, str): + raise RuntimeError("Progress summary JSON missing string 'agent'.") + if not isinstance(check_summary, str): + raise RuntimeError("Progress summary JSON missing string 'check'.") + + return _single_line(agent_summary), _single_line(check_summary) + + +def _single_line(text): + if not text: + return "" + return " ".join(text.replace("\r", " ").split()) + + +def _format_duration(seconds): + if seconds < 0: + seconds = 0 + seconds = int(round(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + parts = [] + if hours: + parts.append(f"{hours}h") + if minutes or hours: + parts.append(f"{minutes}m") + if not hours: + parts.append(f"{seconds}s") + return " ".join(parts) + + +def _print_progress( + attempt, + total, + start_time, + agent_output, + check_output, + cwd, + yolo, + flags, +): + elapsed = time.monotonic() - start_time + remaining = 0 + if attempt: + remaining = (elapsed / attempt) * (total - attempt) + + summary_prompt = _build_progress_prompt(agent_output, check_output) + summary = agent(summary_prompt, cwd, yolo, flags) + agent_summary, check_summary = _progress_result(summary) + + elapsed_text = _format_duration(elapsed) + remaining_text = _format_duration(remaining) + print( + f"Round {attempt}/{total} ({elapsed_text} elapsed, {remaining_text} remaining)", + flush=True, + ) + print(f"Agent: {agent_summary}", flush=True) + print(f"Check: {check_summary}", flush=True) + print("", flush=True) + def _fix_prompt(error): return ( "The verification check failed:\n" @@ -89,6 +178,7 @@ def task( cwd=None, yolo=False, flags=None, + progress=False, ): """Run a prompt with optional checker-driven retries. @@ -100,6 +190,7 @@ def task( cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. + progress: Whether to print progress after each verification round. Returns: The agent's response text when the task succeeds. @@ -107,7 +198,7 @@ def task( Raises: TaskFailed: when the task reaches the maximum attempts without success. """ - result = task_result(prompt, check, n, cwd, yolo, flags) + result = task_result(prompt, check, n, cwd, yolo, flags, progress) if result.success: return result.summary raise TaskFailed(result.summary, result.attempts, result.errors) @@ -120,15 +211,28 @@ def task_result( cwd=None, yolo=False, flags=None, + progress=False, ): """Run a prompt with optional checker-driven retries and return TaskResult. The runner keeps a single session. Each verification attempt uses a fresh, - stateless agent call. + stateless agent call. When progress is True, print a summary each round. """ if check is False: runner = Agent(cwd, yolo, None, flags) + start_time = time.monotonic() summary = runner(prompt) + if progress: + _print_progress( + 1, + 1, + start_time, + summary, + "Verification skipped.", + cwd, + yolo, + flags, + ) return TaskResult(True, summary, 1, None, runner.thread_id) if check is None: check = _default_check(prompt) @@ -138,11 +242,24 @@ def task_result( raise ValueError("n must be >= 0") runner = Agent(cwd, yolo, None, flags) - runner(prompt) + start_time = time.monotonic() + last_output = runner(prompt) check_prompt = _build_check_prompt(check) for attempt in range(n + 1): - success, reason = _check_result(agent(check_prompt, cwd, yolo, flags)) + check_output = agent(check_prompt, cwd, yolo, flags) + success, reason = _check_result(check_output) + if progress: + _print_progress( + attempt + 1, + n + 1, + start_time, + last_output, + check_output, + cwd, + yolo, + flags, + ) if success: summary = runner(_success_prompt()) return TaskResult( @@ -161,7 +278,7 @@ def task_result( reason, runner.thread_id, ) - runner(_fix_prompt(reason)) + last_output = runner(_fix_prompt(reason)) class TaskResult: From fbb224b1896fe70433b5de1a428411a315c78f7c Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 12 Jan 2026 00:05:48 +0100 Subject: [PATCH 07/78] Update ralph prompt and bump version to 0.3.4 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/ralph.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22d3c6a..8096a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.3.3" +version = "0.3.4" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0807769..94e301f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 4a0e4be..8afc12b 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -135,7 +135,7 @@ def run_ralph_loop( elif runner is None: runner = Agent(cwd, yolo, None, flags) - message = runner(prompt) + message = runner(prompt + '\nIf there are multiple paths forward, please use your own best judgement as to which to try first - I trust you!\n') print(message) last_message = message From 54c441a8bf5dfc197cc6b8f6b615114e75f2901e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 13 Jan 2026 11:51:57 +0100 Subject: [PATCH 08/78] Make yolo default and bump version to 0.4.0 --- README.md | 19 ++++++++++--------- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 4 ++-- src/codexapi/cli.py | 21 ++++++++++++++++++--- src/codexapi/ralph.py | 4 ++-- src/codexapi/task.py | 6 +++--- 7 files changed, 37 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9497228..51bfb41 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Resume a session and print the thread id to stderr: codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` +Use `--no-yolo` to run Codex with `--full-auto` instead. + Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. @@ -85,27 +87,27 @@ codexapi ralph --cancel --cwd /path/to/project ## API -### `agent(prompt, cwd=None, yolo=False, flags=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None) -> str` Runs a single Codex turn and returns only the agent's message. Any reasoning items are filtered out. - `prompt` (str): prompt to send to Codex. - `cwd` (str | PathLike | None): working directory for the Codex session. -- `yolo` (bool): pass `--yolo` to Codex when true. +- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. -### `Agent(cwd=None, yolo=False, thread_id=None, flags=None)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. - `__call__(prompt) -> str`: send a prompt to Codex and return the message. - `thread_id -> str | None`: expose the underlying session id once created. -- `yolo` (bool): pass `--yolo` to Codex when true. +- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. -### `task(prompt, check=None, n=10, cwd=None, yolo=False, flags=None) -> str` +### `task(prompt, check=None, n=10, cwd=None, yolo=True, flags=None) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum attempts are reached. @@ -113,12 +115,12 @@ Raises `TaskFailed` when the maximum attempts are reached. - `check` (str | None | False): custom check prompt, default checker, or `False` to skip. - `n` (int): maximum number of retries after a failed check. -### `task_result(prompt, check=None, n=10, cwd=None, yolo=False, flags=None) -> TaskResult` +### `task_result(prompt, check=None, n=10, cwd=None, yolo=True, flags=None) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. -### `Task(prompt, max_attempts=10, cwd=None, yolo=False, thread_id=None, flags=None)` +### `Task(prompt, max_attempts=10, cwd=None, yolo=True, thread_id=None, flags=None)` Runs a Codex task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return @@ -153,8 +155,7 @@ Exception raised by `task()` when retries are exhausted. - Uses `codex exec --json` and parses JSONL events for `agent_message` items. - Automatically passes `--skip-git-repo-check` so it can run outside a git repo. -- Passes `--full-auto` unless `--yolo` is enabled. -- Passes `--yolo` when enabled (use with care). +- Passes `--yolo` by default (use `--no-yolo` or `yolo=False` for `--full-auto`). - Raises `RuntimeError` if Codex exits non-zero or returns no agent message. ## Configuration diff --git a/pyproject.toml b/pyproject.toml index 8096a4b..9911d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.3.4" +version = "0.4.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 94e301f..98da11b 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -12,4 +12,4 @@ "task", "task_result", ] -__version__ = "0.3.4" +__version__ = "0.4.0" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index d6585d2..6bbf3e9 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -8,7 +8,7 @@ _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") -def agent(prompt, cwd=None, yolo=False, flags=None): +def agent(prompt, cwd=None, yolo=True, flags=None): """Run a single Codex turn and return only the agent's message. Args: @@ -36,7 +36,7 @@ class Agent: def __init__( self, cwd=None, - yolo=False, + yolo=True, thread_id=None, flags=None, ): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index cad39ce..e6dff7b 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -908,7 +908,12 @@ def main(argv=None): help="Prompt to send. Use '-' or omit to read from stdin.", ) run_parser.add_argument("--cwd", help="Working directory for the Codex session.") - run_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + run_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) run_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", @@ -943,7 +948,12 @@ def main(argv=None): help="Max verification retries after a failed check (0 means no retries).", ) task_parser.add_argument("--cwd", help="Working directory for the Codex session.") - task_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + task_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) task_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", @@ -986,7 +996,12 @@ def main(argv=None): help="Start each iteration with a fresh Agent context.", ) ralph_parser.add_argument("--cwd", help="Working directory for the Codex session.") - ralph_parser.add_argument("--yolo", action="store_true", help="Pass --yolo to Codex.") + ralph_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) ralph_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 8afc12b..21c916c 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -15,7 +15,7 @@ def run_ralph_loop( prompt, cwd=None, - yolo=False, + yolo=True, flags=None, max_iterations=0, completion_promise=None, @@ -135,7 +135,7 @@ def run_ralph_loop( elif runner is None: runner = Agent(cwd, yolo, None, flags) - message = runner(prompt + '\nIf there are multiple paths forward, please use your own best judgement as to which to try first - I trust you!\n') + message = runner(prompt + '\nIf there are multiple paths forward, you MUST use your own best judgement as to which to try first! Do not ask the user to choose an option, they hereby give you explciit permission to pick the best one yourself.\n') print(message) last_message = message diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 18e61f1..c907fd8 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -176,7 +176,7 @@ def task( check=None, n=10, cwd=None, - yolo=False, + yolo=True, flags=None, progress=False, ): @@ -209,7 +209,7 @@ def task_result( check=None, n=10, cwd=None, - yolo=False, + yolo=True, flags=None, progress=False, ): @@ -319,7 +319,7 @@ def __init__( prompt, max_attempts=10, cwd=None, - yolo=False, + yolo=True, thread_id=None, flags=None, ): From 196bd22b64902b5c6529141da0f1fbd8a3e52013 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 17 Jan 2026 22:56:55 +0100 Subject: [PATCH 09/78] Mincor task prompt improvement --- src/codexapi/task.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index c907fd8..3a4d5a6 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -10,8 +10,9 @@ _CHECK_PREFIX = ( "You are a verification agent. Explore this workspace and carefully evaluate it " - "against the check below. Collect evidence by running any tests and/or reading " + "against the task below. Collect evidence by running any tests and/or reading " "and tracing through code, but do not change any of the code.\n" + "Act as a collaborator who wants to give the task owner all the information they need to succeed.\n" "Return only JSON with keys: success (boolean) and reason (string).\n" "Set success to true only if everything matches the intent." ) From 9437952182e86cfcb3ffc5f08ab7b720d40fc81f Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 18 Jan 2026 00:56:19 +0100 Subject: [PATCH 10/78] Add task files, foreach runner, and top turn count --- README.md | 30 +++- examples/foreach_list.txt | 5 + examples/foreach_task_readonly.yaml | 10 ++ examples/task_file_readonly.yaml | 11 ++ examples/task_temp_hello.py | 2 +- pyproject.toml | 7 +- src/codexapi/__init__.py | 5 +- src/codexapi/cli.py | 109 ++++++++++++- src/codexapi/foreach.py | 230 ++++++++++++++++++++++++++++ src/codexapi/task.py | 20 ++- src/codexapi/taskfile.py | 108 +++++++++++++ 11 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 examples/foreach_list.txt create mode 100644 examples/foreach_task_readonly.yaml create mode 100644 examples/task_file_readonly.yaml create mode 100644 src/codexapi/foreach.py create mode 100644 src/codexapi/taskfile.py diff --git a/README.md b/README.md index 51bfb41..c763b72 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ echo "Say hello." | codexapi run ```bash codexapi task "Fix the failing tests." --max-iterations 5 +codexapi task -f task.yaml ``` Show running sessions and their latest activity: @@ -85,6 +86,13 @@ codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3 codexapi ralph --cancel --cwd /path/to/project ``` +Run a task file across a list file: + +```bash +codexapi foreach list.txt task.yaml +codexapi foreach list.txt task.yaml -n 4 +``` + ## API ### `agent(prompt, cwd=None, yolo=True, flags=None) -> str` @@ -129,7 +137,7 @@ Runs a Codex task with checker-driven retries. Subclass it and implement - `__call__() -> TaskResult`: run the task. - `set_up()`: optional setup hook. - `tear_down()`: optional cleanup hook. -- `check() -> str | None`: return an error description or `None`/`""`. +- `check(output=None) -> str | None`: return an error description or `None`/`""`. `output` is the last agent response. - `on_success(result)`: optional success hook. - `on_failure(result)`: optional failure hook. @@ -151,6 +159,26 @@ Exception raised by `task()` when retries are exhausted. - `attempts` (int | None): attempts made when the task failed. - `errors` (str | None): last checker error, if any. +### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult` + +Runs a task file over a list of items, updating the list file in place. + +- `list_file` (str | PathLike): path to the list file to process. +- `task_file` (str | PathLike): YAML task file (must include `prompt`). +- `n` (int | None): limit parallelism to N (default: run all items in parallel). +- `cwd` (str | PathLike | None): working directory for the Codex session. +- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). +- `flags` (str | None): extra CLI flags to pass to Codex. + +### `ForeachResult(succeeded, failed, skipped, results)` + +Simple result object returned by `foreach()`. + +- `succeeded` (int): number of successful items. +- `failed` (int): number of failed items. +- `skipped` (int): number of items skipped (already marked in the list file). +- `results` (list[tuple]): `(item, success, summary)` entries for items that ran. + ## Behavior notes - Uses `codex exec --json` and parses JSONL events for `agent_message` items. diff --git a/examples/foreach_list.txt b/examples/foreach_list.txt new file mode 100644 index 0000000..3e903dd --- /dev/null +++ b/examples/foreach_list.txt @@ -0,0 +1,5 @@ +README.md +src/codexapi/agent.py + src/codexapi/task.py + +examples/task_file_readonly.yaml diff --git a/examples/foreach_task_readonly.yaml b/examples/foreach_task_readonly.yaml new file mode 100644 index 0000000..07cfde3 --- /dev/null +++ b/examples/foreach_task_readonly.yaml @@ -0,0 +1,10 @@ +prompt: | + Read {{item}} and summarize it in two sentences. + Do not edit any files or run commands that change the working tree. +check: | + Verify the summary mentions {{item}} and is exactly two sentences. + Return an empty string if OK, otherwise explain what is missing. +on_success: "Acknowledge completion for {{item}}." +on_failure: | + Explain why the check failed for {{item}}. +tear_down: "No cleanup needed." diff --git a/examples/task_file_readonly.yaml b/examples/task_file_readonly.yaml new file mode 100644 index 0000000..73ceee4 --- /dev/null +++ b/examples/task_file_readonly.yaml @@ -0,0 +1,11 @@ +prompt: | + Summarize this repository in exactly three bullet points. + Do not edit any files or run commands that change the working tree. +set_up: | + List the top-level files and folders. + Do not modify anything. +check: | + Confirm the summary has exactly three bullets and mentions CodexAPI. + Return an empty string if OK, otherwise explain what is missing. +on_success: "Acknowledge completion in one short sentence." +tear_down: "No cleanup needed." diff --git a/examples/task_temp_hello.py b/examples/task_temp_hello.py index da356bf..b397ffa 100644 --- a/examples/task_temp_hello.py +++ b/examples/task_temp_hello.py @@ -31,7 +31,7 @@ def tear_down(self): self._tmpdir = None logger.debug("tear_down: deleted %s", self.cwd) - def check(self): + def check(self, output=None): logger.debug("check: checking %s contains hello.py", self.cwd) hello_path = os.path.join(self.cwd, "hello.py") if not os.path.exists(hello_path): diff --git a/pyproject.toml b/pyproject.toml index 9911d15..1eba3aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.4.0" +version = "0.5.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" @@ -15,7 +15,10 @@ classifiers = [ "Operating System :: OS Independent", ] -dependencies = [] +dependencies = [ + "PyYAML>=6.0", + "tqdm>=4.64", +] [project.scripts] codexapi = "codexapi.cli:main" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 98da11b..a8615f0 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,15 +1,18 @@ """Minimal Python API for running the Codex CLI.""" from .agent import Agent, agent +from .foreach import ForeachResult, foreach from .task import Task, TaskFailed, TaskResult, task, task_result __all__ = [ "Agent", + "ForeachResult", "Task", "TaskFailed", "TaskResult", "agent", + "foreach", "task", "task_result", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index e6dff7b..34a7ced 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -12,8 +12,10 @@ from pathlib import Path from .agent import Agent, agent +from .foreach import foreach from .ralph import cancel_ralph_loop, run_ralph_loop from .task import TaskFailed, task +from .taskfile import AutoTask, load_task_file _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -38,6 +40,7 @@ "in": "IN", "out": "OUT", "turn": "TURN", + "turns": "NTRN", "model": "MODEL", "effort": "EFF", "perm": "PERM", @@ -121,6 +124,27 @@ def _tail_lines(path): return text.splitlines() +def _count_turns(path): + event_count = 0 + response_count = 0 + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if "\"type\":\"event_msg\"" in line and "\"type\":\"user_message\"" in line: + event_count += 1 + continue + if "\"type\":\"response_item\"" in line and "\"role\":\"user\"" in line and "\"type\":\"message\"" in line: + response_count += 1 + except OSError: + return None + + if event_count: + return event_count + if response_count: + return response_count + return None + + def _extract_text(content): if isinstance(content, str): return content @@ -364,6 +388,7 @@ def _summarize_session(path, mtime): total_usage = None meta = {} subagent = None + turns = _count_turns(path) for line in _tail_lines(path): try: @@ -485,6 +510,7 @@ def _summarize_session(path, mtime): "last_user_ts": last_user_ts, "last_agent_ts": last_agent_ts, "last_event_kind": last_event_kind, + "turns": turns, "meta": meta, } @@ -604,6 +630,7 @@ def _layout_columns(width, id_width, show): ("in", ">"), ("out", ">"), ("turn", ">"), + ("turns", ">"), ] widths = { "id": id_width, @@ -612,6 +639,7 @@ def _layout_columns(width, id_width, show): "in": 7, "out": 7, "turn": 7, + "turns": 5, } mins = {} @@ -684,6 +712,8 @@ def _format_session(session, layout): else: turn_seconds = None turn_str = _format_duration(turn_seconds) + turns = session.get("turns") + turns_str = "-" if turns is None else str(turns) meta = session.get("meta") or {} model = meta.get("model") or meta.get("model_provider") or "-" effort = meta.get("effort") or "-" @@ -702,6 +732,7 @@ def _format_session(session, layout): "in": total_in, "out": total_out, "turn": turn_str, + "turns": _truncate_head(str(turns_str), widths.get("turns", 0)), "model": _truncate_head(str(model), widths.get("model", 0)), "effort": _truncate_head(str(effort), widths.get("effort", 0)), "perm": _truncate_head(str(perm), widths.get("perm", 0)), @@ -932,6 +963,11 @@ def main(argv=None): "task", help="Run a task with verification retries.", ) + task_parser.add_argument( + "-f", + "--task-file", + help="YAML task file to run.", + ) task_parser.add_argument( "prompt", nargs="?", @@ -944,8 +980,8 @@ def main(argv=None): task_parser.add_argument( "--max-iterations", type=int, - default=10, - help="Max verification retries after a failed check (0 means no retries).", + default=None, + help="Max verification retries after a failed check (0 means no retries). Defaults to 10.", ) task_parser.add_argument("--cwd", help="Working directory for the Codex session.") task_parser.add_argument( @@ -1007,6 +1043,35 @@ def main(argv=None): help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + foreach_parser = subparsers.add_parser( + "foreach", + help="Run a task file over a list file.", + ) + foreach_parser.add_argument( + "list_file", + help="Path to the list file to process.", + ) + foreach_parser.add_argument( + "task_file", + help="Path to the YAML task file.", + ) + foreach_parser.add_argument( + "-n", + type=int, + help="Limit parallelism to N.", + ) + foreach_parser.add_argument("--cwd", help="Working directory for the Codex session.") + foreach_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) + foreach_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to Codex (quoted as needed).", + ) + subparsers.add_parser( "top", help="Show running Codex sessions.", @@ -1020,6 +1085,21 @@ def main(argv=None): _run_top([]) return + if args.command == "foreach": + if args.n is not None and args.n < 1: + raise SystemExit("-n must be >= 1.") + result = foreach( + args.list_file, + args.task_file, + args.n, + args.cwd, + args.yolo, + args.flags, + ) + if result.failed: + raise SystemExit(1) + return + if args.command == "ralph": if args.cancel: if args.prompt: @@ -1031,6 +1111,29 @@ def main(argv=None): print(cancel_ralph_loop(args.cwd)) return + if args.command == "task" and args.task_file: + if args.prompt: + raise SystemExit("task -f does not take a prompt.") + if args.check is not None: + raise SystemExit("--check is not allowed with -f.") + if args.max_iterations is not None: + raise SystemExit("--max-iterations is not allowed with -f.") + task_def = load_task_file(args.task_file) + task_runner = AutoTask( + task_def, + None, + 10, + args.cwd, + args.yolo, + None, + args.flags, + ) + result = task_runner() + print(result.summary) + if not result.success: + raise SystemExit(1) + return + prompt = _read_prompt(args.prompt) exit_code = 0 @@ -1048,6 +1151,8 @@ def main(argv=None): ) return if args.command == "task": + if args.max_iterations is None: + args.max_iterations = 10 if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") check = args.check if args.check is not None else prompt diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py new file mode 100644 index 0000000..74b3a49 --- /dev/null +++ b/src/codexapi/foreach.py @@ -0,0 +1,230 @@ +"""Run a task file over a list of items with resumable progress.""" + +import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from tqdm import tqdm + +from .taskfile import AutoTask, load_task_file + +_STATUS_RUNNING = "⏳" +_STATUS_SUCCESS = "✅" +_STATUS_FAILED = "❌" +_STATUS_SET = {_STATUS_RUNNING, _STATUS_SUCCESS, _STATUS_FAILED} + + +class ForeachResult: + """Outcome summary for a foreach run.""" + + def __init__(self, succeeded, failed, skipped, results): + self.succeeded = succeeded + self.failed = failed + self.skipped = skipped + self.results = results + + def __repr__(self): + return ( + "ForeachResult(" + f"succeeded={self.succeeded}, " + f"failed={self.failed}, " + f"skipped={self.skipped}, " + f"results={self.results!r}" + ")" + ) + + +def foreach( + list_file, + task_file, + n=None, + cwd=None, + yolo=True, + flags=None, +): + """Run a task file over each item in list_file and update the file.""" + task_def = load_task_file(task_file) + lines, ends_with_newline = _read_lines(list_file) + items, skipped = _collect_items(lines) + + if not items: + return ForeachResult(0, 0, skipped, []) + + max_workers = _max_workers(n, len(items)) + lock = threading.Lock() + results = [] + counts = { + "running": 0, + "success": 0, + "failed": 0, + } + + progress = tqdm(total=len(items)) + try: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [] + for index, item in items: + futures.append( + executor.submit( + _run_item, + index, + item, + task_def, + lines, + ends_with_newline, + list_file, + cwd, + yolo, + flags, + counts, + results, + progress, + lock, + ) + ) + for future in as_completed(futures): + future.result() + finally: + progress.close() + + return ForeachResult( + counts["success"], + counts["failed"], + skipped, + results, + ) + + +def _max_workers(n, total): + if n is None: + return total + if n < 1: + raise ValueError("n must be >= 1") + if n > total: + return total + return n + + +def _read_lines(path): + with open(path, "r", encoding="utf-8") as handle: + data = handle.read() + ends_with_newline = data.endswith("\n") + return data.splitlines(), ends_with_newline + + +def _write_lines(path, lines, ends_with_newline): + text = "\n".join(lines) + if ends_with_newline: + text += "\n" + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + +def _collect_items(lines): + items = [] + skipped = 0 + for index, line in enumerate(lines): + if not line.strip(): + continue + if _status_marker(line): + skipped += 1 + continue + items.append((index, line)) + return items, skipped + + +def _status_marker(line): + if not line: + return None + marker = line[0] + if marker in _STATUS_SET: + return marker + return None + + +def _status_text(counts): + return ( + f"{_STATUS_RUNNING}: {counts['running']}, " + f"{_STATUS_SUCCESS}: {counts['success']}, " + f"{_STATUS_FAILED}: {counts['failed']}" + ) + + +def _single_line(text): + if not text: + return "" + return text.replace("\r", " ").replace("\n", " ") + + +def _format_turns(used, total): + used_text = "?" if used is None else str(used) + total_text = "?" if total is None else str(total) + return f"[turns: {used_text}/{total_text}]" + + +def _run_item( + index, + item, + task_def, + lines, + ends_with_newline, + list_file, + cwd, + yolo, + flags, + counts, + results, + progress, + lock, +): + running_line = f"{_STATUS_RUNNING} {item}" + with lock: + lines[index] = running_line + _write_lines(list_file, lines, ends_with_newline) + counts["running"] += 1 + progress.set_postfix_str(_status_text(counts)) + + summary = "" + success = False + attempts = None + max_attempts = None + try: + task = AutoTask( + task_def, + item, + 10, + cwd, + yolo, + None, + flags, + ) + max_attempts = task.max_attempts + result = task() + success = result.success + attempts = result.attempts + summary = result.summary or "" + except Exception as exc: + summary = f"{type(exc).__name__}: {exc}" + success = False + + summary = _single_line(summary) + turns = _format_turns(attempts, max_attempts) + if summary: + summary = f"{summary} {turns}" + else: + summary = turns + status = _STATUS_SUCCESS if success else _STATUS_FAILED + final_line = f"{status} {item} | {summary}" + + with lock: + lines[index] = final_line + _write_lines(list_file, lines, ends_with_newline) + counts["running"] -= 1 + if success: + counts["success"] += 1 + else: + counts["failed"] += 1 + results.append((item, success, summary)) + progress.update(1) + progress.set_postfix_str(_status_text(counts)) + tqdm.write(final_line, file=sys.stdout) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 3a4d5a6..9a79502 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -142,9 +142,11 @@ def _print_progress( def _fix_prompt(error): return ( - "The verification check failed:\n" + "Thanks for your work. An automated verifier reported these issues:\n" f"{error}\n\n" - "Please fix the issues while staying close to the original intent." + "Take another look and see whether you agree and, if so, please take this " + "feedback into consideration and use it to continue to make progress " + "towards our original goal and intent." ) @@ -329,6 +331,7 @@ def __init__( self.prompt = prompt self.max_attempts = max_attempts self.cwd = cwd + self.last_output = None self.agent = Agent( cwd, yolo, @@ -342,8 +345,9 @@ def set_up(self): def tear_down(self): """Delete the directory etc.""" - def check(self): + def check(self, output=None): """ Check if the task is done, return a string describing the problems if not. + The output argument is the last agent response. This can be any combination of running tests, python code or running an agent with a specific prompt in self.cwd. """ @@ -357,9 +361,11 @@ def on_failure(self, result): def fix_prompt(self, error): """Build a prompt that asks the agent to fix checker failures.""" return ( - "The following checks failed:\n" + "Thanks for your work. An automated verifier reported these issues:\n" f"{error}\n\n" - "Can you please dive in and see if you agree with this assessment, then fix these issues while staying as close as you can to the spirit of the original task?" + "Take another look and see whether you agree and, if so, please take " + "this feedback into consideration and use it to continue to make " + "progress towards our original goal and intent." ) def success_prompt(self): @@ -383,18 +389,20 @@ def __call__(self, debug=False): # Start with the initial prompt output = self.agent(self.prompt) + self.last_output = output if debug: _logger.debug("Initial output: %s", output) # Try correcting it up to max_attempts times for attempt in range(self.max_attempts): - error = self.check() + error = self.check(self.last_output) if debug: _logger.debug("Check error: %s", error) if error: # if there were errors, tell the agent to fix them output = self.agent(self.fix_prompt(error)) + self.last_output = output if debug: _logger.debug("Fix output: %s", output) else: diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py new file mode 100644 index 0000000..4a1ea62 --- /dev/null +++ b/src/codexapi/taskfile.py @@ -0,0 +1,108 @@ +"""Load YAML task files and map them onto Task hooks.""" + +import yaml + +from .agent import agent +from .task import Task + +_ITEM_TOKEN = "{{item}}" + + +def load_task_file(path): + """Load a YAML task file and return a normalized task definition.""" + if not path: + raise ValueError("task file path is required") + with open(path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError("Task file must be a YAML mapping.") + + prompt = data.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("Task file missing non-empty 'prompt'.") + + return { + "prompt": prompt, + "set_up": _optional_str(data.get("set_up")), + "tear_down": _optional_str(data.get("tear_down")), + "check": _optional_str(data.get("check")), + "on_success": _optional_str(data.get("on_success")), + "on_failure": _optional_str(data.get("on_failure")), + } + + +def _optional_str(value): + if value is None: + return None + if isinstance(value, str): + return value if value.strip() else None + raise ValueError("Task file values must be strings.") + + +def _render(text, item): + if text is None: + return None + if item is None: + return text + return text.replace(_ITEM_TOKEN, item) + + +class AutoTask(Task): + """Task subclass that maps YAML strings onto Task hooks.""" + + def __init__( + self, + config, + item=None, + max_attempts=10, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + ): + if not isinstance(config, dict): + raise TypeError("config must be a task definition dict") + self._config = config + self._item = "" if item is None else str(item) + self._yolo = yolo + self._flags = flags + prompt = _render(config.get("prompt"), self._item) + super().__init__(prompt, max_attempts, cwd, yolo, thread_id, flags) + + def _hook(self, name): + return _render(self._config.get(name), self._item) + + def set_up(self): + text = self._hook("set_up") + if text: + agent(text, self.cwd, self._yolo, self._flags) + + def tear_down(self): + text = self._hook("tear_down") + if text: + agent(text, self.cwd, self._yolo, self._flags) + + def check(self, output=None): + text = self._hook("check") + if not text: + return None + last_output = output if output is not None else self.last_output + last_output = last_output or "" + if last_output: + prompt = f"{text}\n\nAGENT OUTPUT:\n{last_output}" + else: + prompt = text + result = agent(prompt, self.cwd, self._yolo, self._flags) + if not isinstance(result, str) or not result.strip(): + return None + return result + + def on_success(self, result): + text = self._hook("on_success") + if text: + agent(text, self.cwd, self._yolo, self._flags) + + def on_failure(self, result): + text = self._hook("on_failure") + if text: + agent(text, self.cwd, self._yolo, self._flags) From b39ee28a548ed955bd45365f4954a8cd4c8d588b Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 23 Jan 2026 11:48:17 +0100 Subject: [PATCH 11/78] Default ralph to fresh context --- README.md | 4 +++- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 26 ++++++++++++++++++++------ src/codexapi/ralph.py | 6 +++--- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c763b72..1163a5b 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,12 @@ Use `--no-yolo` to run Codex with `--full-auto` instead. Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. +By default each iteration starts with a fresh Agent context; use +`--ralph-reuse` to keep a single shared context across iterations. ```bash codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5 -codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3 +codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations 3 codexapi ralph --cancel --cwd /path/to/project ``` diff --git a/pyproject.toml b/pyproject.toml index 1eba3aa..6525b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.0" +version = "0.5.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index a8615f0..c748a7f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 34a7ced..eac55cb 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -920,8 +920,8 @@ def main(argv=None): " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n" " Only output the promise when it is completely and unequivocally TRUE.\n" " Cancel by deleting .codexapi/ralph-loop.local.md or running codexapi ralph --cancel.\n" - " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n" - " each iteration (no shared context).\n" + " Default starts each iteration with a fresh Agent context; use --ralph-reuse\n" + " to reuse a single Codex thread across iterations.\n" ) parser = argparse.ArgumentParser( prog="codexapi", @@ -1026,10 +1026,20 @@ def main(argv=None): "--completion-promise", help="Promise text to match in ....", ) - ralph_parser.add_argument( + ralph_fresh_group = ralph_parser.add_mutually_exclusive_group() + ralph_fresh_group.add_argument( "--ralph-fresh", action="store_true", - help="Start each iteration with a fresh Agent context.", + dest="ralph_fresh", + default=None, + help="Start each iteration with a fresh Agent context (default).", + ) + ralph_fresh_group.add_argument( + "--ralph-reuse", + action="store_false", + dest="ralph_fresh", + default=None, + help="Reuse the same Agent context each iteration.", ) ralph_parser.add_argument("--cwd", help="Working directory for the Codex session.") ralph_parser.add_argument( @@ -1104,12 +1114,16 @@ def main(argv=None): if args.cancel: if args.prompt: raise SystemExit("ralph --cancel takes no prompt.") - if args.completion_promise or args.ralph_fresh: - raise SystemExit("--completion-promise/--ralph-fresh are not allowed with --cancel.") + if args.completion_promise or args.ralph_fresh is not None: + raise SystemExit( + "--completion-promise/--ralph-fresh/--ralph-reuse are not allowed with --cancel." + ) if args.max_iterations != 0: raise SystemExit("--max-iterations is not allowed with --cancel.") print(cancel_ralph_loop(args.cwd)) return + if args.ralph_fresh is None: + args.ralph_fresh = True if args.command == "task" and args.task_file: if args.prompt: diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 21c916c..fc4c1c1 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -19,7 +19,7 @@ def run_ralph_loop( flags=None, max_iterations=0, completion_promise=None, - fresh=False, + fresh=True, ): """Run a Ralph Wiggum-style loop that repeats the same prompt. @@ -37,8 +37,8 @@ def run_ralph_loop( may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop. - By default a single Agent instance is reused for shared context. Set - `fresh=True` to create a new Agent each iteration for a clean context. + By default each iteration uses a fresh Agent for a clean context. Set + `fresh=False` to reuse a single Agent instance for shared context. Cancel by deleting the state file or running `codexapi ralph --cancel`. """ if not isinstance(prompt, str) or not prompt.strip(): From 6fe5f438af49315facbd0b49d9e467c022f3bb84 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 23 Jan 2026 12:25:53 +0100 Subject: [PATCH 12/78] Add science mode --- README.md | 8 +++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 115 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1163a5b..d441ba5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,14 @@ codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations codexapi ralph --cancel --cwd /path/to/project ``` +Science mode wraps a short task in a science prompt and runs it through the +Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. + +```bash +codexapi science "hyper-optimize the kernel cycles" +codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3 +``` + Run a task file across a list file: ```bash diff --git a/pyproject.toml b/pyproject.toml index 6525b21..c7bd9a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.1" +version = "0.5.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index c748a7f..e292364 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.1" +__version__ = "0.5.2" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index eac55cb..03ed0b4 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -24,6 +24,22 @@ _TAIL_MAX_BYTES = 4 * 1024 * 1024 _TAIL_MIN_LINES = 200 _ROLL_OUT_PREFIX = "rollout-" +_SCIENCE_TEMPLATE = ( + "Good afternoon! We have a fun task today - take a good look around this repo " + "and review all relevant knowledge you have. Our task is to {task}. We're " + "working step by step in a scientific manner so if there's a SCIENCE.md read " + "that first to understand the progress of the rest of the team so far. Then " + "try as hard as you can to find a good path forwards - run as many experiments " + "as you want and take your time, we have all night. Note down everything you " + "learn that wasn't obvious in a knowledge section in SCIENCE.md and any " + "experiments in a similar section. The aim is to move the ball forwards, " + "either by getting closer to the goal ruling out a hypothesis that doesn't " + "whilst understanding why. Try your best and have fun with this one! If you " + "think of several options, pick one and run with it - I will not be available " + "to make decisions for you, I give you my full permission to explore and make " + "your own best judgement towards our goal! Remember to update SCIENCE.md. " + "Good hunting!" +) _TOOL_LABELS = { "apply_patch": "Editing files", "exec_command": "Running command", @@ -64,6 +80,12 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) +def _science_prompt(task): + if not isinstance(task, str) or not task.strip(): + raise SystemExit("Science task must be a non-empty string.") + return _SCIENCE_TEMPLATE.replace("{task}", task.strip()) + + def _truncate_head(text, limit): if limit <= 0: return "" @@ -923,6 +945,11 @@ def main(argv=None): " Default starts each iteration with a fresh Agent context; use --ralph-reuse\n" " to reuse a single Codex thread across iterations.\n" ) + science_help = ( + "Science mode (science command):\n" + " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" + " Default uses --yolo. Use --no-yolo to run --full-auto instead.\n" + ) parser = argparse.ArgumentParser( prog="codexapi", description="Run Codex via the codexapi wrapper.", @@ -1053,6 +1080,59 @@ def main(argv=None): help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + science_parser = subparsers.add_parser( + "science", + help="Run a science-mode Ralph loop.", + epilog=science_help, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + science_parser.add_argument( + "task", + nargs="?", + help="Short task description. Use '-' or omit to read from stdin.", + ) + science_parser.add_argument( + "--max-iterations", + type=int, + default=0, + help="Max iterations for the loop (0 means unlimited).", + ) + science_parser.add_argument( + "--cancel", + action="store_true", + help="Cancel the Ralph loop state in the target cwd.", + ) + science_parser.add_argument( + "--completion-promise", + help="Promise text to match in ....", + ) + science_fresh_group = science_parser.add_mutually_exclusive_group() + science_fresh_group.add_argument( + "--ralph-fresh", + action="store_true", + dest="ralph_fresh", + default=None, + help="Start each iteration with a fresh Agent context (default).", + ) + science_fresh_group.add_argument( + "--ralph-reuse", + action="store_false", + dest="ralph_fresh", + default=None, + help="Reuse the same Agent context each iteration.", + ) + science_parser.add_argument("--cwd", help="Working directory for the Codex session.") + science_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) + science_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to Codex (quoted as needed).", + ) + foreach_parser = subparsers.add_parser( "foreach", help="Run a task file over a list file.", @@ -1124,6 +1204,20 @@ def main(argv=None): return if args.ralph_fresh is None: args.ralph_fresh = True + if args.command == "science": + if args.cancel: + if args.task: + raise SystemExit("science --cancel takes no task.") + if args.completion_promise or args.ralph_fresh is not None: + raise SystemExit( + "--completion-promise/--ralph-fresh/--ralph-reuse are not allowed with --cancel." + ) + if args.max_iterations != 0: + raise SystemExit("--max-iterations is not allowed with --cancel.") + print(cancel_ralph_loop(args.cwd)) + return + if args.ralph_fresh is None: + args.ralph_fresh = True if args.command == "task" and args.task_file: if args.prompt: @@ -1148,7 +1242,12 @@ def main(argv=None): raise SystemExit(1) return - prompt = _read_prompt(args.prompt) + prompt_source = None + if args.command in ("run", "ralph", "task"): + prompt_source = args.prompt + elif args.command == "science": + prompt_source = args.task + prompt = _read_prompt(prompt_source) exit_code = 0 if args.command == "ralph": @@ -1164,6 +1263,20 @@ def main(argv=None): args.ralph_fresh, ) return + if args.command == "science": + if args.max_iterations < 0: + raise SystemExit("--max-iterations must be >= 0.") + science_prompt = _science_prompt(prompt) + run_ralph_loop( + science_prompt, + args.cwd, + args.yolo, + args.flags, + args.max_iterations, + args.completion_promise, + args.ralph_fresh, + ) + return if args.command == "task": if args.max_iterations is None: args.max_iterations = 10 From 84fcb79a10108dca21974261c7c5591d7ca1146e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 23 Jan 2026 15:30:26 +0100 Subject: [PATCH 13/78] Flush ralph output --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/ralph.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7bd9a1..9d897b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.2" +version = "0.5.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index e292364..9fb7431 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.2" +__version__ = "0.5.3" diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index fc4c1c1..a557980 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -47,6 +47,8 @@ def run_ralph_loop( raise TypeError("completion_promise must be a string or None") if max_iterations < 0: raise ValueError("max_iterations must be >= 0") + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) state_path = _state_path(cwd) _ensure_state_dir(state_path) From 1d5b5773a51799de93a5f2a5dfe718ce086c6d51 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 24 Jan 2026 08:04:15 +0100 Subject: [PATCH 14/78] Refine task iteration handling --- README.md | 22 +- examples/foreach_list.txt | 3 +- examples/foreach_task_readonly.yaml | 1 - examples/task_file_readonly.yaml | 1 - pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 35 +-- src/codexapi/foreach.py | 20 +- src/codexapi/task.py | 331 +++++++++++++++++++--------- src/codexapi/taskfile.py | 108 ++++----- 10 files changed, 325 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index d441ba5..e8fa903 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ echo "Say hello." | codexapi run codexapi task "Fix the failing tests." --max-iterations 5 codexapi task -f task.yaml ``` +Progress is shown by default for `codexapi task`; use `--quiet` to suppress it. + +Task files default to using the standard check prompt for the task. Set `check: "None"` to skip verification. +Use `max_iterations` in the task file to override the default attempt cap (0 means unlimited). +Checks are wrapped with the verifier prompt, include the agent output, and expect JSON with `success`/`reason`. Show running sessions and their latest activity: @@ -125,26 +130,31 @@ the same conversation and returns only the agent's message. - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. -### `task(prompt, check=None, n=10, cwd=None, yolo=True, flags=None) -> str` +### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum attempts are reached. -- `check` (str | None | False): custom check prompt, default checker, or `False` to skip. -- `n` (int): maximum number of retries after a failed check. +- `check` (str | None | False): custom check prompt, default checker, or `False`/`"None"` to skip. +- `max_iterations` (int): maximum number of task attempts (0 means unlimited). +- `progress` (bool): print progress after each verification round. +- `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. -### `task_result(prompt, check=None, n=10, cwd=None, yolo=True, flags=None) -> TaskResult` +### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. +Arguments mirror `task()` (including hooks). ### `Task(prompt, max_attempts=10, cwd=None, yolo=True, thread_id=None, flags=None)` Runs a Codex task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return `None`/`""` when the task passes. +If you do not override `check()`, the default verifier wrapper runs with the +default check prompt and includes the agent output. -- `__call__() -> TaskResult`: run the task. +- `__call__(debug=False, progress=False) -> TaskResult`: run the task. - `set_up()`: optional setup hook. - `tear_down()`: optional cleanup hook. - `check(output=None) -> str | None`: return an error description or `None`/`""`. `output` is the last agent response. @@ -163,7 +173,7 @@ Simple result object returned by `Task.__call__`. ### `TaskFailed` -Exception raised by `task()` when retries are exhausted. +Exception raised by `task()` when attempts are exhausted. - `summary` (str): failure summary text. - `attempts` (int | None): attempts made when the task failed. diff --git a/examples/foreach_list.txt b/examples/foreach_list.txt index 3e903dd..908e612 100644 --- a/examples/foreach_list.txt +++ b/examples/foreach_list.txt @@ -1,5 +1,4 @@ README.md src/codexapi/agent.py - src/codexapi/task.py - +src/codexapi/task.py examples/task_file_readonly.yaml diff --git a/examples/foreach_task_readonly.yaml b/examples/foreach_task_readonly.yaml index 07cfde3..96fb8fe 100644 --- a/examples/foreach_task_readonly.yaml +++ b/examples/foreach_task_readonly.yaml @@ -3,7 +3,6 @@ prompt: | Do not edit any files or run commands that change the working tree. check: | Verify the summary mentions {{item}} and is exactly two sentences. - Return an empty string if OK, otherwise explain what is missing. on_success: "Acknowledge completion for {{item}}." on_failure: | Explain why the check failed for {{item}}. diff --git a/examples/task_file_readonly.yaml b/examples/task_file_readonly.yaml index 73ceee4..a3a613c 100644 --- a/examples/task_file_readonly.yaml +++ b/examples/task_file_readonly.yaml @@ -6,6 +6,5 @@ set_up: | Do not modify anything. check: | Confirm the summary has exactly three bullets and mentions CodexAPI. - Return an empty string if OK, otherwise explain what is missing. on_success: "Acknowledge completion in one short sentence." tear_down: "No cleanup needed." diff --git a/pyproject.toml b/pyproject.toml index 9d897b0..eb8bf30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.3" +version = "0.5.4" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 9fb7431..a2c7772 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.3" +__version__ = "0.5.4" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 03ed0b4..f8590d1 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -14,8 +14,8 @@ from .agent import Agent, agent from .foreach import foreach from .ralph import cancel_ralph_loop, run_ralph_loop -from .task import TaskFailed, task -from .taskfile import AutoTask, load_task_file +from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task +from .taskfile import TaskFile _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -1008,7 +1008,10 @@ def main(argv=None): "--max-iterations", type=int, default=None, - help="Max verification retries after a failed check (0 means no retries). Defaults to 10.", + help=( + "Max agent attempts (0 means unlimited). " + f"Defaults to {DEFAULT_MAX_ITERATIONS}." + ), ) task_parser.add_argument("--cwd", help="Working directory for the Codex session.") task_parser.add_argument( @@ -1022,9 +1025,9 @@ def main(argv=None): help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) task_parser.add_argument( - "--progress", + "--quiet", action="store_true", - help="Print progress after each verification round.", + help="Suppress progress output during verification.", ) ralph_parser = subparsers.add_parser( @@ -1226,17 +1229,15 @@ def main(argv=None): raise SystemExit("--check is not allowed with -f.") if args.max_iterations is not None: raise SystemExit("--max-iterations is not allowed with -f.") - task_def = load_task_file(args.task_file) - task_runner = AutoTask( - task_def, - None, - 10, - args.cwd, - args.yolo, + task_runner = TaskFile( + args.task_file, None, - args.flags, + cwd=args.cwd, + yolo=args.yolo, + thread_id=None, + flags=args.flags, ) - result = task_runner() + result = task_runner(progress=not args.quiet) print(result.summary) if not result.success: raise SystemExit(1) @@ -1279,10 +1280,10 @@ def main(argv=None): return if args.command == "task": if args.max_iterations is None: - args.max_iterations = 10 + args.max_iterations = DEFAULT_MAX_ITERATIONS if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") - check = args.check if args.check is not None else prompt + check = args.check try: message = task( prompt, @@ -1291,7 +1292,7 @@ def main(argv=None): args.cwd, args.yolo, args.flags, - args.progress, + not args.quiet, ) except TaskFailed as exc: message = exc.summary diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index 74b3a49..2c1b165 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -6,7 +6,7 @@ from tqdm import tqdm -from .taskfile import AutoTask, load_task_file +from .taskfile import TaskFile _STATUS_RUNNING = "⏳" _STATUS_SUCCESS = "✅" @@ -43,7 +43,6 @@ def foreach( flags=None, ): """Run a task file over each item in list_file and update the file.""" - task_def = load_task_file(task_file) lines, ends_with_newline = _read_lines(list_file) items, skipped = _collect_items(lines) @@ -69,7 +68,7 @@ def foreach( _run_item, index, item, - task_def, + task_file, lines, ends_with_newline, list_file, @@ -165,7 +164,7 @@ def _format_turns(used, total): def _run_item( index, item, - task_def, + task_file, lines, ends_with_newline, list_file, @@ -189,14 +188,13 @@ def _run_item( attempts = None max_attempts = None try: - task = AutoTask( - task_def, + task = TaskFile( + task_file, item, - 10, - cwd, - yolo, - None, - flags, + cwd=cwd, + yolo=yolo, + thread_id=None, + flags=flags, ) max_attempts = task.max_attempts result = task() diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 9a79502..a4b7ce5 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -10,9 +10,12 @@ _CHECK_PREFIX = ( "You are a verification agent. Explore this workspace and carefully evaluate it " - "against the task below. Collect evidence by running any tests and/or reading " - "and tracing through code, but do not change any of the code.\n" - "Act as a collaborator who wants to give the task owner all the information they need to succeed.\n" + "against the instructions below. Collect evidence by running any tests and/or " + "reading and tracing through code, but do not change any of the code.\n" + "You will receive the task or check instructions first, then the agent output " + "under the heading 'AGENT OUTPUT', which is provided for context and does not " + "replace or supersede collecting your own evidence unless it is clear from the " + "instructions that the agent's output IS the expected output of the task.\n" "Return only JSON with keys: success (boolean) and reason (string).\n" "Set success to true only if everything matches the intent." ) @@ -23,6 +26,7 @@ "Each value must be a single line with no newlines.\n" "Do not run commands or change any files." ) +DEFAULT_MAX_ITERATIONS = 10 def _default_check(prompt): @@ -35,8 +39,27 @@ def _default_check(prompt): ) -def _build_check_prompt(check): - return f"{_CHECK_PREFIX}\n\n{check}\n\n{_CHECK_SUFFIX}" +def _build_check_prompt(check, agent_output): + output = agent_output or "" + return ( + f"{_CHECK_PREFIX}\n\n" + f"{check}\n\n" + "AGENT OUTPUT:\n" + f"{output}\n\n" + f"{_CHECK_SUFFIX}" + ) + + +def _resolve_check_text(prompt, check): + if check is False: + return None, True + if check is None: + return _default_check(prompt), False + if not isinstance(check, str): + raise TypeError("check must be a string or False") + if check.strip() == "None": + return None, True + return check, False def _build_progress_prompt(agent_output, check_output): @@ -123,17 +146,23 @@ def _print_progress( ): elapsed = time.monotonic() - start_time remaining = 0 - if attempt: - remaining = (elapsed / attempt) * (total - attempt) + remaining_text = "unknown" + if total: + if attempt: + remaining = (elapsed / attempt) * (total - attempt) + remaining_text = _format_duration(remaining) summary_prompt = _build_progress_prompt(agent_output, check_output) summary = agent(summary_prompt, cwd, yolo, flags) agent_summary, check_summary = _progress_result(summary) elapsed_text = _format_duration(elapsed) - remaining_text = _format_duration(remaining) + if not total: + round_text = f"Round {attempt}/unlimited" + else: + round_text = f"Round {attempt}/{total}" print( - f"Round {attempt}/{total} ({elapsed_text} elapsed, {remaining_text} remaining)", + f"{round_text} ({elapsed_text} elapsed, {remaining_text} remaining)", flush=True, ) print(f"Agent: {agent_summary}", flush=True) @@ -174,26 +203,42 @@ def __init__(self, summary, attempts=None, errors=None): self.errors = errors +def _validate_hook(name, value): + if value is None: + return None + if isinstance(value, str): + return value + raise TypeError(f"{name} must be a string or None") + + def task( prompt, check=None, - n=10, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, yolo=True, flags=None, progress=False, + set_up=None, + tear_down=None, + on_success=None, + on_failure=None, ): """Run a prompt with optional checker-driven retries. Args: prompt: The task prompt to run. check: False to skip verification, None for the default check, or - a string check prompt. - n: Maximum number of retries after a failed check. + a string check prompt. The string "None" skips verification. + max_iterations: Maximum number of task attempts (0 means unlimited). cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. progress: Whether to print progress after each verification round. + set_up: Optional setup prompt to run before the task. + tear_down: Optional cleanup prompt to run after the task. + on_success: Optional prompt to run after a successful task. + on_failure: Optional prompt to run after a failed task. Returns: The agent's response text when the task succeeds. @@ -201,7 +246,19 @@ def task( Raises: TaskFailed: when the task reaches the maximum attempts without success. """ - result = task_result(prompt, check, n, cwd, yolo, flags, progress) + result = task_result( + prompt, + check, + max_iterations, + cwd, + yolo, + flags, + progress, + set_up, + tear_down, + on_success, + on_failure, + ) if result.success: return result.summary raise TaskFailed(result.summary, result.attempts, result.errors) @@ -210,78 +267,46 @@ def task( def task_result( prompt, check=None, - n=10, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, yolo=True, flags=None, progress=False, + set_up=None, + tear_down=None, + on_success=None, + on_failure=None, ): """Run a prompt with optional checker-driven retries and return TaskResult. The runner keeps a single session. Each verification attempt uses a fresh, stateless agent call. When progress is True, print a summary each round. + + Hook strings mirror task file keys: set_up, tear_down, on_success, on_failure. """ - if check is False: - runner = Agent(cwd, yolo, None, flags) - start_time = time.monotonic() - summary = runner(prompt) - if progress: - _print_progress( - 1, - 1, - start_time, - summary, - "Verification skipped.", - cwd, - yolo, - flags, - ) - return TaskResult(True, summary, 1, None, runner.thread_id) - if check is None: - check = _default_check(prompt) - if not isinstance(check, str): + if max_iterations < 0: + raise ValueError("max_iterations must be >= 0") + if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") - if n < 0: - raise ValueError("n must be >= 0") - - runner = Agent(cwd, yolo, None, flags) - start_time = time.monotonic() - last_output = runner(prompt) - check_prompt = _build_check_prompt(check) - for attempt in range(n + 1): - check_output = agent(check_prompt, cwd, yolo, flags) - success, reason = _check_result(check_output) - if progress: - _print_progress( - attempt + 1, - n + 1, - start_time, - last_output, - check_output, - cwd, - yolo, - flags, - ) - if success: - summary = runner(_success_prompt()) - return TaskResult( - True, - summary, - attempt + 1, - None, - runner.thread_id, - ) - if attempt == n: - summary = runner(_failure_prompt(reason)) - return TaskResult( - False, - summary, - attempt + 1, - reason, - runner.thread_id, - ) - last_output = runner(_fix_prompt(reason)) + set_up_text = _validate_hook("set_up", set_up) + tear_down_text = _validate_hook("tear_down", tear_down) + on_success_text = _validate_hook("on_success", on_success) + on_failure_text = _validate_hook("on_failure", on_failure) + runner = AutoTask( + prompt, + check, + max_iterations, + cwd, + yolo, + None, + flags, + set_up=set_up_text, + tear_down=tear_down_text, + on_success=on_success_text, + on_failure=on_failure_text, + ) + return runner(progress=progress) class TaskResult: @@ -320,18 +345,23 @@ class Task: def __init__( self, prompt, - max_attempts=10, + max_attempts=DEFAULT_MAX_ITERATIONS, cwd=None, yolo=True, thread_id=None, flags=None, ): - if max_attempts < 1: - raise ValueError("max_attempts must be >= 1") + if max_attempts < 0: + raise ValueError("max_attempts must be >= 0") self.prompt = prompt self.max_attempts = max_attempts self.cwd = cwd self.last_output = None + self.last_check_output = None + self.check_skipped = False + self.check_text = None + self._yolo = yolo + self._flags = flags self.agent = Agent( cwd, yolo, @@ -346,11 +376,26 @@ def tear_down(self): """Delete the directory etc.""" def check(self, output=None): - """ Check if the task is done, return a string describing the problems if not. - The output argument is the last agent response. - This can be any combination of running tests, python code or running an agent - with a specific prompt in self.cwd. - """ + """Check if the task is done, return a string describing problems if not. + + The default implementation runs the verifier agent with the standard + check wrapper and expects JSON output. + """ + self.last_check_output = None + self.check_skipped = False + check_text, skip = _resolve_check_text(self.prompt, self.check_text) + if skip: + self.check_skipped = True + return None + last_output = output if output is not None else self.last_output + last_output = last_output or "" + check_prompt = _build_check_prompt(check_text, last_output) + check_output = agent(check_prompt, self.cwd, self._yolo, self._flags) + self.last_check_output = check_output + success, reason = _check_result(check_output) + if success: + return None + return reason def on_success(self, result): """Hook called after a successful task, e.g. commit the changes.""" @@ -365,23 +410,22 @@ def fix_prompt(self, error): f"{error}\n\n" "Take another look and see whether you agree and, if so, please take " "this feedback into consideration and use it to continue to make " - "progress towards our original goal and intent." + "progress towards our original goal and intent. Don't propose next steps, " + "use your best judgement and work towards the goal!" ) def success_prompt(self): """Ask the agent to summarize what it did.""" - return "Awesome - great job! Can you please produce a short summary of what you've done?" + return _success_prompt() def failure_prompt(self, error): """Ask the agent to summarize remaining issues after retries.""" - return ( - "We ran out of attempts. Can you please look back at everything you tried and summarize what it was that made this task too hard to complete, including anything you wish you'd known at the start that would have helped improve things?\n\n" - f"Outstanding issues:\n{error}" - ) + return _failure_prompt(error) - def __call__(self, debug=False): + def __call__(self, debug=False, progress=False): """Run the task with checker-driven retries. If debug is True, log debug messages. + If progress is True, print progress after each verification round. """ try: # If this fails in the middle we will still try to tear down @@ -392,35 +436,106 @@ def __call__(self, debug=False): self.last_output = output if debug: _logger.debug("Initial output: %s", output) - + # Try correcting it up to max_attempts times - for attempt in range(self.max_attempts): + start_time = time.monotonic() + error = None + attempt = 0 + while True: + attempt += 1 error = self.check(self.last_output) if debug: _logger.debug("Check error: %s", error) - - if error: - # if there were errors, tell the agent to fix them - output = self.agent(self.fix_prompt(error)) - self.last_output = output - if debug: - _logger.debug("Fix output: %s", output) - else: - # otherwise get a summary of what was done and run on_success + + if progress: + check_output = self.last_check_output + if self.check_skipped: + check_output = "Verification skipped." + _print_progress( + attempt, + self.max_attempts, + start_time, + self.last_output, + check_output or "", + self.cwd, + self._yolo, + self._flags, + ) + if not error: summary = self.agent(self.success_prompt()) if debug: _logger.debug("Success summary: %s", summary) - result = TaskResult(True, summary, attempt + 1, error, self.agent.thread_id) + result = TaskResult( + True, + summary, + attempt, + None, + self.agent.thread_id, + ) self.on_success(result) return result - - # Ran out of attempts - get a reason why and run on_failure - summary = self.agent(self.failure_prompt(error)) - if debug: - _logger.debug("Failure summary: %s", summary) - result = TaskResult(False, summary, attempt + 1, error, self.agent.thread_id) - self.on_failure(result) - return result + if self.max_attempts and attempt >= self.max_attempts: + summary = self.agent(self.failure_prompt(error)) + if debug: + _logger.debug("Failure summary: %s", summary) + result = TaskResult( + False, + summary, + attempt, + error, + self.agent.thread_id, + ) + self.on_failure(result) + return result + output = self.agent(self.fix_prompt(error)) + self.last_output = output + if debug: + _logger.debug("Fix output: %s", output) finally: # No matter what, once we have set_up we will always tear_down self.tear_down() + + +class AutoTask(Task): + """Task subclass that maps prompt strings onto Task hooks.""" + + def __init__( + self, + prompt, + check=None, + max_attempts=DEFAULT_MAX_ITERATIONS, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + set_up=None, + tear_down=None, + on_success=None, + on_failure=None, + ): + if not (check is None or check is False or isinstance(check, str)): + raise TypeError("check must be a string or False") + if max_attempts < 0: + raise ValueError("max_attempts must be >= 0") + super().__init__(prompt, max_attempts, cwd, yolo, thread_id, flags) + self.check_text = check + self._set_up = _validate_hook("set_up", set_up) + self._tear_down = _validate_hook("tear_down", tear_down) + self._on_success = _validate_hook("on_success", on_success) + self._on_failure = _validate_hook("on_failure", on_failure) + + def _run_hook(self, text): + if text: + agent(text, self.cwd, self._yolo, self._flags) + + def set_up(self): + self._run_hook(self._set_up) + + def tear_down(self): + self._run_hook(self._tear_down) + + def on_success(self, result): + self._run_hook(self._on_success) + + def on_failure(self, result): + self._run_hook(self._on_failure) diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index 4a1ea62..870fd2a 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -2,8 +2,7 @@ import yaml -from .agent import agent -from .task import Task +from .task import AutoTask _ITEM_TOKEN = "{{item}}" @@ -21,6 +20,13 @@ def load_task_file(path): if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("Task file missing non-empty 'prompt'.") + max_iterations = data.get("max_iterations") + if max_iterations is not None: + if not isinstance(max_iterations, int): + raise ValueError("Task file max_iterations must be an integer.") + if max_iterations < 0: + raise ValueError("Task file max_iterations must be >= 0.") + return { "prompt": prompt, "set_up": _optional_str(data.get("set_up")), @@ -28,6 +34,7 @@ def load_task_file(path): "check": _optional_str(data.get("check")), "on_success": _optional_str(data.get("on_success")), "on_failure": _optional_str(data.get("on_failure")), + "max_iterations": max_iterations, } @@ -47,62 +54,59 @@ def _render(text, item): return text.replace(_ITEM_TOKEN, item) -class AutoTask(Task): - """Task subclass that maps YAML strings onto Task hooks.""" +class TaskFile(AutoTask): + """Task subclass that maps a YAML task file onto Task hooks.""" def __init__( self, - config, + path, item=None, - max_attempts=10, + max_iterations=None, cwd=None, yolo=True, thread_id=None, flags=None, ): - if not isinstance(config, dict): - raise TypeError("config must be a task definition dict") - self._config = config - self._item = "" if item is None else str(item) - self._yolo = yolo - self._flags = flags - prompt = _render(config.get("prompt"), self._item) - super().__init__(prompt, max_attempts, cwd, yolo, thread_id, flags) - - def _hook(self, name): - return _render(self._config.get(name), self._item) - - def set_up(self): - text = self._hook("set_up") - if text: - agent(text, self.cwd, self._yolo, self._flags) - - def tear_down(self): - text = self._hook("tear_down") - if text: - agent(text, self.cwd, self._yolo, self._flags) - - def check(self, output=None): - text = self._hook("check") - if not text: - return None - last_output = output if output is not None else self.last_output - last_output = last_output or "" - if last_output: - prompt = f"{text}\n\nAGENT OUTPUT:\n{last_output}" - else: - prompt = text - result = agent(prompt, self.cwd, self._yolo, self._flags) - if not isinstance(result, str) or not result.strip(): - return None - return result - - def on_success(self, result): - text = self._hook("on_success") - if text: - agent(text, self.cwd, self._yolo, self._flags) - - def on_failure(self, result): - text = self._hook("on_failure") - if text: - agent(text, self.cwd, self._yolo, self._flags) + task_def = load_task_file(path) + if max_iterations is None: + max_iterations = task_def.get("max_iterations") + elif not isinstance(max_iterations, int): + raise ValueError("max_iterations must be an integer.") + elif max_iterations < 0: + raise ValueError("max_iterations must be >= 0.") + item_text = "" if item is None else str(item) + rendered = { + "prompt": _render(task_def.get("prompt"), item_text), + "set_up": _render(task_def.get("set_up"), item_text), + "tear_down": _render(task_def.get("tear_down"), item_text), + "check": _render(task_def.get("check"), item_text), + "on_success": _render(task_def.get("on_success"), item_text), + "on_failure": _render(task_def.get("on_failure"), item_text), + } + if max_iterations is None: + super().__init__( + rendered["prompt"], + rendered["check"], + cwd=cwd, + yolo=yolo, + thread_id=thread_id, + flags=flags, + set_up=rendered["set_up"], + tear_down=rendered["tear_down"], + on_success=rendered["on_success"], + on_failure=rendered["on_failure"], + ) + return + super().__init__( + rendered["prompt"], + rendered["check"], + max_iterations, + cwd, + yolo, + thread_id, + flags, + set_up=rendered["set_up"], + tear_down=rendered["tear_down"], + on_success=rendered["on_success"], + on_failure=rendered["on_failure"], + ) From b1294349a52008cc4fece455de6045da73f5b66c Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 24 Jan 2026 08:20:49 +0100 Subject: [PATCH 15/78] Improve task UX and foreach retries --- README.md | 4 +++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 66 ++++++++++++++++++++++++++++++++++++++-- src/codexapi/task.py | 37 ++++++++++++++-------- src/codexapi/taskfile.py | 11 +++++++ 6 files changed, 106 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8fa903..79e71ed 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ echo "Say hello." | codexapi run ```bash codexapi task "Fix the failing tests." --max-iterations 5 codexapi task -f task.yaml +codexapi task -f task.yaml -i README.md ``` Progress is shown by default for `codexapi task`; use `--quiet` to suppress it. +When using `--item`, the task file must include at least one `{{item}}` placeholder. Task files default to using the standard check prompt for the task. Set `check: "None"` to skip verification. Use `max_iterations` in the task file to override the default attempt cap (0 means unlimited). @@ -106,6 +108,8 @@ Run a task file across a list file: ```bash codexapi foreach list.txt task.yaml codexapi foreach list.txt task.yaml -n 4 +codexapi foreach list.txt task.yaml --retry-failed +codexapi foreach list.txt task.yaml --retry-all ``` ## API diff --git a/pyproject.toml b/pyproject.toml index eb8bf30..7e08fa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.4" +version = "0.5.5" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index a2c7772..3cc69c4 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.4" +__version__ = "0.5.5" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f8590d1..96f65bc 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -15,7 +15,7 @@ from .foreach import foreach from .ralph import cancel_ralph_loop, run_ralph_loop from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task -from .taskfile import TaskFile +from .taskfile import TaskFile, load_task_file, task_def_uses_item _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -62,6 +62,7 @@ "perm": "PERM", "cwd": "CWD", } +_FOREACH_STATUS_MARKERS = {"⏳", "✅", "❌"} def _read_prompt(prompt): @@ -871,6 +872,37 @@ def _print_top_once(show): print(_format_session(session, layout)) +def _clean_foreach_list(path, retry_failed, retry_all): + with open(path, "r", encoding="utf-8") as handle: + data = handle.read() + ends_with_newline = data.endswith("\n") + lines = data.splitlines() + + cleaned = [] + changed = False + for line in lines: + new_line = line + if retry_all or (retry_failed and new_line.startswith("❌")): + if new_line and new_line[0] in _FOREACH_STATUS_MARKERS: + new_line = new_line[1:] + if new_line.startswith(" "): + new_line = new_line[1:] + pipe = new_line.find("|") + if pipe != -1: + new_line = new_line[:pipe].rstrip() + if new_line != line: + changed = True + cleaned.append(new_line) + + if not changed: + return + text = "\n".join(cleaned) + if ends_with_newline: + text += "\n" + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + def _run_top(argv): if argv and argv[0] in ("-h", "--help"): print("usage: codexapi top") @@ -995,6 +1027,11 @@ def main(argv=None): "--task-file", help="YAML task file to run.", ) + task_parser.add_argument( + "-i", + "--item", + help="Item value for task files that use {{item}} placeholders.", + ) task_parser.add_argument( "prompt", nargs="?", @@ -1148,6 +1185,17 @@ def main(argv=None): "task_file", help="Path to the YAML task file.", ) + foreach_retry_group = foreach_parser.add_mutually_exclusive_group() + foreach_retry_group.add_argument( + "--retry-failed", + action="store_true", + help="Reset failed (❌) items for re-run.", + ) + foreach_retry_group.add_argument( + "--retry-all", + action="store_true", + help="Reset all items for re-run.", + ) foreach_parser.add_argument( "-n", type=int, @@ -1181,6 +1229,12 @@ def main(argv=None): if args.command == "foreach": if args.n is not None and args.n < 1: raise SystemExit("-n must be >= 1.") + if args.retry_failed or args.retry_all: + _clean_foreach_list( + args.list_file, + args.retry_failed, + args.retry_all, + ) result = foreach( args.list_file, args.task_file, @@ -1225,13 +1279,19 @@ def main(argv=None): if args.command == "task" and args.task_file: if args.prompt: raise SystemExit("task -f does not take a prompt.") + if args.item is not None: + task_def = load_task_file(args.task_file) + if not task_def_uses_item(task_def): + raise SystemExit( + "task -f --item requires {{item}} in the task file." + ) if args.check is not None: raise SystemExit("--check is not allowed with -f.") if args.max_iterations is not None: raise SystemExit("--max-iterations is not allowed with -f.") task_runner = TaskFile( args.task_file, - None, + args.item, cwd=args.cwd, yolo=args.yolo, thread_id=None, @@ -1279,6 +1339,8 @@ def main(argv=None): ) return if args.command == "task": + if args.item is not None: + raise SystemExit("--item is only supported with -f.") if args.max_iterations is None: args.max_iterations = DEFAULT_MAX_ITERATIONS if args.max_iterations < 0: diff --git a/src/codexapi/task.py b/src/codexapi/task.py index a4b7ce5..f24e38c 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -134,7 +134,17 @@ def _format_duration(seconds): return " ".join(parts) -def _print_progress( +def _progress_round_label(attempt, total): + if not total: + return f"Round {attempt}/unlimited" + return f"Round {attempt}/{total}" + + +def _print_progress_start(attempt, total): + print(_progress_round_label(attempt, total), flush=True) + + +def _print_progress_result( attempt, total, start_time, @@ -143,13 +153,13 @@ def _print_progress( cwd, yolo, flags, + success, ): elapsed = time.monotonic() - start_time remaining = 0 remaining_text = "unknown" - if total: - if attempt: - remaining = (elapsed / attempt) * (total - attempt) + if total and attempt: + remaining = (elapsed / attempt) * (total - attempt) remaining_text = _format_duration(remaining) summary_prompt = _build_progress_prompt(agent_output, check_output) @@ -157,16 +167,13 @@ def _print_progress( agent_summary, check_summary = _progress_result(summary) elapsed_text = _format_duration(elapsed) - if not total: - round_text = f"Round {attempt}/unlimited" - else: - round_text = f"Round {attempt}/{total}" + print(f"Agent: {agent_summary}", flush=True) + print(f"Check: {check_summary}", flush=True) + verdict = "success" if success else "failure" print( - f"{round_text} ({elapsed_text} elapsed, {remaining_text} remaining)", + f"Verdict: {verdict} ({elapsed_text} elapsed, {remaining_text} remaining)", flush=True, ) - print(f"Agent: {agent_summary}", flush=True) - print(f"Check: {check_summary}", flush=True) print("", flush=True) def _fix_prompt(error): @@ -443,6 +450,11 @@ def __call__(self, debug=False, progress=False): attempt = 0 while True: attempt += 1 + if progress: + _print_progress_start( + attempt, + self.max_attempts, + ) error = self.check(self.last_output) if debug: _logger.debug("Check error: %s", error) @@ -451,7 +463,7 @@ def __call__(self, debug=False, progress=False): check_output = self.last_check_output if self.check_skipped: check_output = "Verification skipped." - _print_progress( + _print_progress_result( attempt, self.max_attempts, start_time, @@ -460,6 +472,7 @@ def __call__(self, debug=False, progress=False): self.cwd, self._yolo, self._flags, + not error, ) if not error: summary = self.agent(self.success_prompt()) diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index 870fd2a..9b4ca8e 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -54,6 +54,17 @@ def _render(text, item): return text.replace(_ITEM_TOKEN, item) +def task_def_uses_item(task_def): + """Return True if a task definition includes the {{item}} placeholder.""" + if not isinstance(task_def, dict): + raise TypeError("task definition must be a dict") + for key in ("prompt", "set_up", "tear_down", "check", "on_success", "on_failure"): + value = task_def.get(key) + if isinstance(value, str) and _ITEM_TOKEN in value: + return True + return False + + class TaskFile(AutoTask): """Task subclass that maps a YAML task file onto Task hooks.""" From 119634e87cf8d42d2a7b5f96572243b6197398b1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 13:53:23 +0100 Subject: [PATCH 16/78] Align task progress timing and suppress CLI summary --- README.md | 10 +- examples/example_task_progress.sh | 1 + pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 8 +- src/codexapi/task.py | 240 +++++++++++++++++++----------- 6 files changed, 167 insertions(+), 96 deletions(-) create mode 100755 examples/example_task_progress.sh diff --git a/README.md b/README.md index 79e71ed..09e4fa3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run ``` -`codexapi task` exits with code 0 on success and 1 on failure, printing the summary. +`codexapi task` exits with code 0 on success and 1 on failure. ```bash codexapi task "Fix the failing tests." --max-iterations 5 @@ -68,6 +68,12 @@ Task files default to using the standard check prompt for the task. Set `check: Use `max_iterations` in the task file to override the default attempt cap (0 means unlimited). Checks are wrapped with the verifier prompt, include the agent output, and expect JSON with `success`/`reason`. +Example task progress run: + +```bash +./examples/example_task_progress.sh +``` + Show running sessions and their latest activity: ```bash @@ -141,7 +147,7 @@ Raises `TaskFailed` when the maximum attempts are reached. - `check` (str | None | False): custom check prompt, default checker, or `False`/`"None"` to skip. - `max_iterations` (int): maximum number of task attempts (0 means unlimited). -- `progress` (bool): print progress after each verification round. +- `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. ### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> TaskResult` diff --git a/examples/example_task_progress.sh b/examples/example_task_progress.sh new file mode 100755 index 0000000..7191d38 --- /dev/null +++ b/examples/example_task_progress.sh @@ -0,0 +1 @@ +codexapi task "The goal is to increase /tmp/work_done.txt towards 5 by incrementing it once and then return control to the user/verifier agent for their feedback before doing so again. Step by step is the way. Initialize at 0 if it does not exist. Verifier: report success when the value has reached the target." diff --git a/pyproject.toml b/pyproject.toml index 7e08fa8..158d99f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.5" +version = "0.5.6" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 3cc69c4..74e01b8 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.5" +__version__ = "0.5.6" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 96f65bc..ee0bce9 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1298,7 +1298,6 @@ def main(argv=None): flags=args.flags, ) result = task_runner(progress=not args.quiet) - print(result.summary) if not result.success: raise SystemExit(1) return @@ -1310,6 +1309,7 @@ def main(argv=None): prompt_source = args.task prompt = _read_prompt(prompt_source) exit_code = 0 + message = None if args.command == "ralph": if args.max_iterations < 0: @@ -1347,7 +1347,7 @@ def main(argv=None): raise SystemExit("--max-iterations must be >= 0.") check = args.check try: - message = task( + task( prompt, check, args.max_iterations, @@ -1357,7 +1357,6 @@ def main(argv=None): not args.quiet, ) except TaskFailed as exc: - message = exc.summary exit_code = 1 else: use_session = args.thread_id or args.print_thread_id @@ -1374,7 +1373,8 @@ def main(argv=None): else: message = agent(prompt, args.cwd, args.yolo, args.flags) - print(message) + if message is not None: + print(message) if exit_code: raise SystemExit(exit_code) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index f24e38c..8ca2656 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -5,6 +5,7 @@ import time from .agent import Agent, agent +from tqdm import tqdm _logger = logging.getLogger(__name__) @@ -20,11 +21,13 @@ "Set success to true only if everything matches the intent." ) _CHECK_SUFFIX = "JSON only. No markdown or extra text." -_PROGRESS_PROMPT = ( - "Summarize the outputs below in one line each.\n" - "Return only JSON with keys: agent (string) and check (string).\n" - "Each value must be a single line with no newlines.\n" - "Do not run commands or change any files." +_ESTIMATE_PROMPT = ( + "Estimate remaining work in story points for the task below.\n" + "You may inspect the repo (read files, git status/diff), but do not run tests.\n" + "Do not change any files.\n" + "Use the task prompt, current repo state, and latest agent/check outputs.\n" + "Return only JSON with keys: remaining (number) and summary (string).\n" + "summary must be a single line describing agent + verifier status." ) DEFAULT_MAX_ITERATIONS = 10 @@ -62,14 +65,32 @@ def _resolve_check_text(prompt, check): return check, False -def _build_progress_prompt(agent_output, check_output): - return ( - f"{_PROGRESS_PROMPT}\n\n" - "AGENT OUTPUT:\n" - f"{agent_output}\n\n" - "CHECK OUTPUT:\n" - f"{check_output}" +def _build_estimate_prompt(prompt, agent_output, check_output, previous_total): + agent_text = agent_output.strip() or "(no agent output yet)" + check_text = check_output.strip() or "(no check output yet)" + lines = [ + _ESTIMATE_PROMPT, + "", + "TASK:", + "```", + prompt, + "```", + ] + if previous_total is not None: + lines.append( + f"This task was previously estimated at about {previous_total} story points." + ) + lines.extend( + [ + "", + "AGENT OUTPUT:", + agent_text, + "", + "CHECK OUTPUT:", + check_text, + ] ) + return "\n".join(lines) def _check_result(output): @@ -91,25 +112,29 @@ def _check_result(output): return success, reason.strip() -def _progress_result(output): +def _estimate_result(output): try: data = json.loads(output) except json.JSONDecodeError as exc: raise RuntimeError( - f"Progress summary returned invalid JSON: {exc}" + f"Estimate returned invalid JSON: {exc}" ) from exc if not isinstance(data, dict): - raise RuntimeError("Progress summary JSON must be an object.") + raise RuntimeError("Estimate JSON must be an object.") - agent_summary = data.get("agent") - check_summary = data.get("check") - if not isinstance(agent_summary, str): - raise RuntimeError("Progress summary JSON missing string 'agent'.") - if not isinstance(check_summary, str): - raise RuntimeError("Progress summary JSON missing string 'check'.") + remaining = data.get("remaining") + summary = data.get("summary") + if not isinstance(remaining, (int, float)): + raise RuntimeError("Estimate JSON missing numeric 'remaining'.") + if not isinstance(summary, str): + raise RuntimeError("Estimate JSON missing string 'summary'.") - return _single_line(agent_summary), _single_line(check_summary) + remaining = int(round(remaining)) + if remaining < 0: + remaining = 0 + + return remaining, _single_line(summary) def _single_line(text): @@ -118,63 +143,36 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) -def _format_duration(seconds): +def _format_elapsed(seconds): if seconds < 0: seconds = 0 seconds = int(round(seconds)) hours, remainder = divmod(seconds, 3600) minutes, seconds = divmod(remainder, 60) - parts = [] - if hours: - parts.append(f"{hours}h") - if minutes or hours: - parts.append(f"{minutes}m") - if not hours: - parts.append(f"{seconds}s") - return " ".join(parts) - - -def _progress_round_label(attempt, total): - if not total: - return f"Round {attempt}/unlimited" - return f"Round {attempt}/{total}" - - -def _print_progress_start(attempt, total): - print(_progress_round_label(attempt, total), flush=True) - - -def _print_progress_result( - attempt, - total, - start_time, - agent_output, - check_output, - cwd, - yolo, - flags, - success, -): - elapsed = time.monotonic() - start_time - remaining = 0 - remaining_text = "unknown" - if total and attempt: - remaining = (elapsed / attempt) * (total - attempt) - remaining_text = _format_duration(remaining) - - summary_prompt = _build_progress_prompt(agent_output, check_output) - summary = agent(summary_prompt, cwd, yolo, flags) - agent_summary, check_summary = _progress_result(summary) - - elapsed_text = _format_duration(elapsed) - print(f"Agent: {agent_summary}", flush=True) - print(f"Check: {check_summary}", flush=True) - verdict = "success" if success else "failure" - print( - f"Verdict: {verdict} ({elapsed_text} elapsed, {remaining_text} remaining)", - flush=True, + return f"{hours}h{minutes:02d}m{seconds:02d}s" + + +def _format_turns(attempt, total): + if total: + width = max(2, len(str(total))) + total_text = str(total) + else: + width = 2 + total_text = "∞" + attempt_text = f"{attempt:0{width}d}" + return f"{attempt_text}/{total_text}" + + +def estimate(prompt, agent_output, check_output, cwd, yolo, flags, previous_total): + estimate_prompt = _build_estimate_prompt( + prompt, + agent_output or "", + check_output or "", + previous_total, ) - print("", flush=True) + output = agent(estimate_prompt, cwd, yolo, flags) + return _estimate_result(output) + def _fix_prompt(error): return ( @@ -241,7 +239,7 @@ def task( cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. - progress: Whether to print progress after each verification round. + progress: Whether to show a tqdm progress bar with status updates. set_up: Optional setup prompt to run before the task. tear_down: Optional cleanup prompt to run after the task. on_success: Optional prompt to run after a successful task. @@ -287,7 +285,7 @@ def task_result( """Run a prompt with optional checker-driven retries and return TaskResult. The runner keeps a single session. Each verification attempt uses a fresh, - stateless agent call. When progress is True, print a summary each round. + stateless agent call. When progress is True, show progress updates each round. Hook strings mirror task file keys: set_up, tear_down, on_success, on_failure. """ @@ -369,6 +367,9 @@ def __init__( self.check_text = None self._yolo = yolo self._flags = flags + self._progress_enabled = False + self._progress_bar = None + self._progress_total = None self.agent = Agent( cwd, yolo, @@ -410,6 +411,30 @@ def on_success(self, result): def on_failure(self, result): """Hook called after a failed run, e.g. log the failure reason.""" + def on_progress( + self, + turns, + max_turns, + total_estimate, + remaining_estimate, + status_line, + ): + """Hook called with progress updates.""" + if not self._progress_enabled: + return + if self._progress_bar is None: + self._progress_bar = tqdm(total=total_estimate) + if total_estimate != self._progress_bar.total: + self._progress_bar.total = total_estimate + current = total_estimate - remaining_estimate + if current < 0: + current = 0 + if self._progress_bar.n != current: + self._progress_bar.n = current + self._progress_bar.refresh() + if status_line: + tqdm.write(status_line, file=self._progress_bar.fp) + def fix_prompt(self, error): """Build a prompt that asks the agent to fix checker failures.""" return ( @@ -432,12 +457,35 @@ def failure_prompt(self, error): def __call__(self, debug=False, progress=False): """Run the task with checker-driven retries. If debug is True, log debug messages. - If progress is True, print progress after each verification round. + If progress is True, show a tqdm progress bar with status updates. """ try: # If this fails in the middle we will still try to tear down self.set_up() + self._progress_enabled = progress + if progress: + remaining, _summary = estimate( + self.prompt, + "", + "", + self.cwd, + self._yolo, + self._flags, + None, + ) + self._progress_total = remaining + start_time = time.monotonic() + self.on_progress( + 0, + self.max_attempts, + self._progress_total, + remaining, + None, + ) + else: + start_time = time.monotonic() + # Start with the initial prompt output = self.agent(self.prompt) self.last_output = output @@ -445,16 +493,10 @@ def __call__(self, debug=False, progress=False): _logger.debug("Initial output: %s", output) # Try correcting it up to max_attempts times - start_time = time.monotonic() error = None attempt = 0 while True: attempt += 1 - if progress: - _print_progress_start( - attempt, - self.max_attempts, - ) error = self.check(self.last_output) if debug: _logger.debug("Check error: %s", error) @@ -463,16 +505,36 @@ def __call__(self, debug=False, progress=False): check_output = self.last_check_output if self.check_skipped: check_output = "Verification skipped." - _print_progress_result( - attempt, - self.max_attempts, - start_time, - self.last_output, + remaining, summary = estimate( + self.prompt, + self.last_output or "", check_output or "", self.cwd, self._yolo, self._flags, - not error, + self._progress_total, + ) + total_estimate = self._progress_total + if total_estimate is None or remaining > total_estimate: + total_estimate = remaining + self._progress_total = total_estimate + elapsed = _format_elapsed(time.monotonic() - start_time) + status_prefix = ( + f"[{_format_turns(attempt, self.max_attempts)} @ {elapsed}]" + ) + is_final = not error or ( + self.max_attempts and attempt >= self.max_attempts + ) + if is_final: + marker = "✅" if not error else "❌" + summary = f"{marker} {summary}".strip() + status_line = f"{status_prefix}: {summary}".rstrip() + self.on_progress( + attempt, + self.max_attempts, + total_estimate, + remaining, + status_line, ) if not error: summary = self.agent(self.success_prompt()) @@ -507,6 +569,8 @@ def __call__(self, debug=False, progress=False): finally: # No matter what, once we have set_up we will always tear_down self.tear_down() + if self._progress_bar is not None: + self._progress_bar.close() class AutoTask(Task): From e2855444360d7daf75f5cebc84fa5d59e3f6baf8 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 14:03:17 +0100 Subject: [PATCH 17/78] Rename attempts to iterations --- README.md | 16 ++++----- src/codexapi/cli.py | 2 +- src/codexapi/foreach.py | 10 +++--- src/codexapi/task.py | 74 +++++++++++++++++++++-------------------- 4 files changed, 52 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 09e4fa3..643d579 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Progress is shown by default for `codexapi task`; use `--quiet` to suppress it. When using `--item`, the task file must include at least one `{{item}}` placeholder. Task files default to using the standard check prompt for the task. Set `check: "None"` to skip verification. -Use `max_iterations` in the task file to override the default attempt cap (0 means unlimited). +Use `max_iterations` in the task file to override the default iteration cap (0 means unlimited). Checks are wrapped with the verifier prompt, include the agent output, and expect JSON with `success`/`reason`. Example task progress run: @@ -143,10 +143,10 @@ the same conversation and returns only the agent's message. ### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` Runs a task with checker-driven retries and returns the success summary. -Raises `TaskFailed` when the maximum attempts are reached. +Raises `TaskFailed` when the maximum iterations are reached. - `check` (str | None | False): custom check prompt, default checker, or `False`/`"None"` to skip. -- `max_iterations` (int): maximum number of task attempts (0 means unlimited). +- `max_iterations` (int): maximum number of task iterations (0 means unlimited). - `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. @@ -156,7 +156,7 @@ Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. Arguments mirror `task()` (including hooks). -### `Task(prompt, max_attempts=10, cwd=None, yolo=True, thread_id=None, flags=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None)` Runs a Codex task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return @@ -171,22 +171,22 @@ default check prompt and includes the agent output. - `on_success(result)`: optional success hook. - `on_failure(result)`: optional failure hook. -### `TaskResult(success, summary, attempts, errors, thread_id)` +### `TaskResult(success, summary, iterations, errors, thread_id)` Simple result object returned by `Task.__call__`. - `success` (bool): whether the task completed successfully. - `summary` (str): agent summary of what happened. -- `attempts` (int): how many attempts were used. +- `iterations` (int): how many iterations were used. - `errors` (str | None): last checker error, if any. - `thread_id` (str | None): Codex thread id for the session. ### `TaskFailed` -Exception raised by `task()` when attempts are exhausted. +Exception raised by `task()` when iterations are exhausted. - `summary` (str): failure summary text. -- `attempts` (int | None): attempts made when the task failed. +- `iterations` (int | None): iterations made when the task failed. - `errors` (str | None): last checker error, if any. ### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult` diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index ee0bce9..d207bf3 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1046,7 +1046,7 @@ def main(argv=None): type=int, default=None, help=( - "Max agent attempts (0 means unlimited). " + "Max agent iterations (0 means unlimited). " f"Defaults to {DEFAULT_MAX_ITERATIONS}." ), ) diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index 2c1b165..547c90d 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -185,8 +185,8 @@ def _run_item( summary = "" success = False - attempts = None - max_attempts = None + iterations = None + max_iterations = None try: task = TaskFile( task_file, @@ -196,17 +196,17 @@ def _run_item( thread_id=None, flags=flags, ) - max_attempts = task.max_attempts + max_iterations = task.max_iterations result = task() success = result.success - attempts = result.attempts + iterations = result.iterations summary = result.summary or "" except Exception as exc: summary = f"{type(exc).__name__}: {exc}" success = False summary = _single_line(summary) - turns = _format_turns(attempts, max_attempts) + turns = _format_turns(iterations, max_iterations) if summary: summary = f"{summary} {turns}" else: diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 8ca2656..6972dd7 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -152,15 +152,17 @@ def _format_elapsed(seconds): return f"{hours}h{minutes:02d}m{seconds:02d}s" -def _format_turns(attempt, total): +def _format_turns(iteration, total): if total: - width = max(2, len(str(total))) + width = len(str(total)) total_text = str(total) else: - width = 2 + width = len(str(iteration)) total_text = "∞" - attempt_text = f"{attempt:0{width}d}" - return f"{attempt_text}/{total_text}" + if width < 1: + width = 1 + iteration_text = f"{iteration:0{width}d}" + return f"{iteration_text}/{total_text}" def estimate(prompt, agent_output, check_output, cwd, yolo, flags, previous_total): @@ -190,21 +192,21 @@ def _success_prompt(): def _failure_prompt(error): return ( - "We ran out of attempts. Summarize what you did and what is still failing.\n\n" + "We ran out of iterations. Summarize what you did and what is still failing.\n\n" f"Outstanding issues:\n{error}" ) class TaskFailed(RuntimeError): - """Raised when a task hits the maximum attempts without success.""" + """Raised when a task hits the maximum iterations without success.""" - def __init__(self, summary, attempts=None, errors=None): - message = "Task failed after maximum attempts." + def __init__(self, summary, iterations=None, errors=None): + message = "Task failed after maximum iterations." if summary: message = f"{message}\n{summary}" super().__init__(message) self.summary = summary - self.attempts = attempts + self.iterations = iterations self.errors = errors @@ -235,7 +237,7 @@ def task( prompt: The task prompt to run. check: False to skip verification, None for the default check, or a string check prompt. The string "None" skips verification. - max_iterations: Maximum number of task attempts (0 means unlimited). + max_iterations: Maximum number of task iterations (0 means unlimited). cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. @@ -249,7 +251,7 @@ def task( The agent's response text when the task succeeds. Raises: - TaskFailed: when the task reaches the maximum attempts without success. + TaskFailed: when the task reaches the maximum iterations without success. """ result = task_result( prompt, @@ -266,7 +268,7 @@ def task( ) if result.success: return result.summary - raise TaskFailed(result.summary, result.attempts, result.errors) + raise TaskFailed(result.summary, result.iterations, result.errors) def task_result( @@ -284,7 +286,7 @@ def task_result( ): """Run a prompt with optional checker-driven retries and return TaskResult. - The runner keeps a single session. Each verification attempt uses a fresh, + The runner keeps a single session. Each verification iteration uses a fresh, stateless agent call. When progress is True, show progress updates each round. Hook strings mirror task file keys: set_up, tear_down, on_success, on_failure. @@ -317,10 +319,10 @@ def task_result( class TaskResult: """Outcome summary for a task run.""" - def __init__(self, success, summary, attempts, errors, thread_id): + def __init__(self, success, summary, iterations, errors, thread_id): self.success = success self.summary = summary - self.attempts = attempts + self.iterations = iterations self.errors = errors self.thread_id = thread_id @@ -328,7 +330,7 @@ def __repr__(self): return ( "TaskResult(" f"success={self.success}, " - f"attempts={self.attempts}, " + f"iterations={self.iterations}, " f"errors={self.errors!r}, " f"thread_id={self.thread_id!r}, " f"summary={self.summary!r}" @@ -350,16 +352,16 @@ class Task: def __init__( self, prompt, - max_attempts=DEFAULT_MAX_ITERATIONS, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, yolo=True, thread_id=None, flags=None, ): - if max_attempts < 0: - raise ValueError("max_attempts must be >= 0") + if max_iterations < 0: + raise ValueError("max_iterations must be >= 0") self.prompt = prompt - self.max_attempts = max_attempts + self.max_iterations = max_iterations self.cwd = cwd self.last_output = None self.last_check_output = None @@ -478,7 +480,7 @@ def __call__(self, debug=False, progress=False): start_time = time.monotonic() self.on_progress( 0, - self.max_attempts, + self.max_iterations, self._progress_total, remaining, None, @@ -492,11 +494,11 @@ def __call__(self, debug=False, progress=False): if debug: _logger.debug("Initial output: %s", output) - # Try correcting it up to max_attempts times + # Try correcting it up to max_iterations times error = None - attempt = 0 + iteration = 0 while True: - attempt += 1 + iteration += 1 error = self.check(self.last_output) if debug: _logger.debug("Check error: %s", error) @@ -520,18 +522,18 @@ def __call__(self, debug=False, progress=False): self._progress_total = total_estimate elapsed = _format_elapsed(time.monotonic() - start_time) status_prefix = ( - f"[{_format_turns(attempt, self.max_attempts)} @ {elapsed}]" + f"[{_format_turns(iteration, self.max_iterations)} @ {elapsed}]" ) is_final = not error or ( - self.max_attempts and attempt >= self.max_attempts + self.max_iterations and iteration >= self.max_iterations ) if is_final: marker = "✅" if not error else "❌" summary = f"{marker} {summary}".strip() status_line = f"{status_prefix}: {summary}".rstrip() self.on_progress( - attempt, - self.max_attempts, + iteration, + self.max_iterations, total_estimate, remaining, status_line, @@ -543,20 +545,20 @@ def __call__(self, debug=False, progress=False): result = TaskResult( True, summary, - attempt, + iteration, None, self.agent.thread_id, ) self.on_success(result) return result - if self.max_attempts and attempt >= self.max_attempts: + if self.max_iterations and iteration >= self.max_iterations: summary = self.agent(self.failure_prompt(error)) if debug: _logger.debug("Failure summary: %s", summary) result = TaskResult( False, summary, - attempt, + iteration, error, self.agent.thread_id, ) @@ -580,7 +582,7 @@ def __init__( self, prompt, check=None, - max_attempts=DEFAULT_MAX_ITERATIONS, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, yolo=True, thread_id=None, @@ -592,9 +594,9 @@ def __init__( ): if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") - if max_attempts < 0: - raise ValueError("max_attempts must be >= 0") - super().__init__(prompt, max_attempts, cwd, yolo, thread_id, flags) + if max_iterations < 0: + raise ValueError("max_iterations must be >= 0") + super().__init__(prompt, max_iterations, cwd, yolo, thread_id, flags) self.check_text = check self._set_up = _validate_hook("set_up", set_up) self._tear_down = _validate_hook("tear_down", tear_down) From 36f459420b2010c92f20a091dc2d939302957c1c Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 15:05:37 +0100 Subject: [PATCH 18/78] Add GitHub Project task integration --- README.md | 10 ++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 70 +++++++++- src/codexapi/gh_integration.py | 229 +++++++++++++++++++++++++++++++++ src/codexapi/task.py | 9 +- 6 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 src/codexapi/gh_integration.py diff --git a/README.md b/README.md index 643d579..5051bd5 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ Task files default to using the standard check prompt for the task. Set `check: Use `max_iterations` in the task file to override the default iteration cap (0 means unlimited). Checks are wrapped with the verifier prompt, include the agent output, and expect JSON with `success`/`reason`. +Take tasks from a GitHub Project (requires `gh-task`): + +```bash +codexapi task -p owner/projects/3 -n "Your Name" -s Backlog task_a.yaml task_b.yaml +``` + +Task labels are derived from task filenames (basename without extension). The +issue title/body become `{{item}}` after removing any existing `## Progress` +section. + Example task progress run: ```bash diff --git a/pyproject.toml b/pyproject.toml index 158d99f..36611ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.6" +version = "0.5.8" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 74e01b8..7b1c448 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.6" +__version__ = "0.5.8" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index d207bf3..f2e5ea7 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1033,9 +1033,25 @@ def main(argv=None): help="Item value for task files that use {{item}} placeholders.", ) task_parser.add_argument( - "prompt", - nargs="?", - help="Prompt to send. Use '-' or omit to read from stdin.", + "-p", + "--project", + help="GitHub Project reference to pull tasks from.", + ) + task_parser.add_argument( + "-s", + "--status", + default="Backlog", + help="Status name to take from when using --project (default: Backlog).", + ) + task_parser.add_argument( + "-n", + "--name", + help="Owner label name for gh-task when using --project.", + ) + task_parser.add_argument( + "task_args", + nargs="*", + help="Prompt to send (no --project) or task files (with --project).", ) task_parser.add_argument( "--check", @@ -1276,8 +1292,40 @@ def main(argv=None): if args.ralph_fresh is None: args.ralph_fresh = True + if args.command == "task" and args.project: + if args.task_file: + raise SystemExit("task --project does not allow -f.") + if args.item is not None: + raise SystemExit("--item is only supported with -f.") + if args.check is not None: + raise SystemExit("--check is not allowed with --project.") + if args.max_iterations is not None: + raise SystemExit("--max-iterations is not allowed with --project.") + if not args.name: + raise SystemExit("--name is required with --project.") + if not args.task_args: + raise SystemExit("task --project requires one or more task files.") + try: + from .gh_integration import GhTaskRunner + except ImportError as exc: + raise SystemExit("gh-task is required for --project. Install it with pip.") from exc + + task_runner = GhTaskRunner( + args.project, + args.name, + args.task_args, + args.status, + args.cwd, + args.yolo, + args.flags, + ) + result = task_runner(progress=not args.quiet) + if not result.success: + raise SystemExit(1) + return + if args.command == "task" and args.task_file: - if args.prompt: + if args.task_args: raise SystemExit("task -f does not take a prompt.") if args.item is not None: task_def = load_task_file(args.task_file) @@ -1303,11 +1351,13 @@ def main(argv=None): return prompt_source = None - if args.command in ("run", "ralph", "task"): + prompt = None + if args.command in ("run", "ralph"): prompt_source = args.prompt elif args.command == "science": prompt_source = args.task - prompt = _read_prompt(prompt_source) + if args.command != "task": + prompt = _read_prompt(prompt_source) exit_code = 0 message = None @@ -1339,6 +1389,8 @@ def main(argv=None): ) return if args.command == "task": + if args.project: + raise SystemExit("task --project already handled earlier.") if args.item is not None: raise SystemExit("--item is only supported with -f.") if args.max_iterations is None: @@ -1347,6 +1399,12 @@ def main(argv=None): raise SystemExit("--max-iterations must be >= 0.") check = args.check try: + task_args = args.task_args or [] + if len(task_args) > 1: + raise SystemExit("task takes a single prompt unless --project is used.") + if task_args: + prompt_source = task_args[0] + prompt = _read_prompt(prompt_source) task( prompt, check, diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py new file mode 100644 index 0000000..8f9a8b4 --- /dev/null +++ b/src/codexapi/gh_integration.py @@ -0,0 +1,229 @@ +import logging +import re +import time +from pathlib import Path + +from tqdm import tqdm + +from gh_task.project import Project + +from .taskfile import TaskFile + + +_logger = logging.getLogger(__name__) + +_PROGRESS_HEADER = "## Progress" +_SUCCESS_LABEL = "✓" +_FAILURE_LABEL = "⨉" +_SUCCESS_COLOR = "2da44e" +_FAILURE_COLOR = "d73a4a" + + +def _canonical_task_name(path): + return Path(path).stem + + +def _task_file_map(task_files): + mapping = {} + for path in task_files: + name = _canonical_task_name(path) + if not name: + raise ValueError(f"Task file name is empty: {path}") + key = name.lower() + if key in mapping: + raise ValueError(f"Duplicate task name '{name}' for {path} and {mapping[key][1]}") + mapping[key] = (name, path) + if not mapping: + raise ValueError("At least one task file is required") + return mapping + + +def _issue_url(issue): + if issue.url: + return issue.url + return f"https://github.com/{issue.repo}/issues/{issue.number}" + + +def _match_task_file(issue, task_map): + labels = issue.labels or [] + matches = [] + for label in labels: + key = label.strip().lower() + if key in task_map: + matches.append((label, task_map[key][1])) + if not matches: + raise ValueError(f"Issue {_issue_url(issue)} has no matching task label") + if len(matches) > 1: + details = ", ".join(f"{label} -> {path}" for label, path in matches) + raise ValueError( + f"Issue {_issue_url(issue)} matches multiple task labels: {details}" + ) + return matches[0][1] + + +def _strip_progress_section(body): + if not body: + return "" + match = re.search(r"(?m)^## Progress\\s*$", body) + if not match: + return body.strip() + return body[:match.start()].rstrip() + + +def _format_item_text(issue, description): + title = issue.title or "" + url = _issue_url(issue) + description = description or "" + return f"Issue: {url}\nTitle: {title}\nDescription: {description}\n" + + +def _format_status_line(status_line): + match = re.match(r"^\\[(?P[^ ]+) @ (?P[^\\]]+)\\]:\\s*(?P.*)$", status_line) + if not match: + return status_line + summary = match.group("summary").strip() + prefix = f"`[{match.group('turns')} {match.group('elapsed')}]`" + if summary: + return f"{prefix} {summary}" + return prefix + + +def _format_progress_bar(total, remaining, start_time): + if total is None: + total = 0 + current = total - remaining + if current < 0: + current = 0 + elapsed = 0.0 + if start_time is not None: + elapsed = time.monotonic() - start_time + total_for_bar = total if total > 0 else 1 + return tqdm.format_meter(current, total_for_bar, elapsed, ncols=80) + + +def _render_progress_section(base_body, status_line, bar_text): + parts = [ + _PROGRESS_HEADER, + "", + status_line, + "", + "```", + bar_text, + "```", + ] + section = "\n".join(parts).rstrip() + if base_body: + return f"{base_body.rstrip()}\n\n{section}\n" + return f"{section}\n" + + +class GhTaskFile(TaskFile): + def __init__( + self, + path, + issue, + project, + item_text, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + ): + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags) + self.issue = issue + self.project = project + self._progress_updates = True + + def on_progress( + self, + iterations, + max_iterations, + total_estimate, + remaining_estimate, + status_line, + ): + super().on_progress( + iterations, + max_iterations, + total_estimate, + remaining_estimate, + status_line, + ) + try: + self.project.set_estimate(self.issue, remaining_estimate) + except Exception as exc: + _logger.warning("Failed to update estimate for issue %s", _issue_url(self.issue), exc_info=exc) + if not status_line: + return + try: + body = self.project.get_issue_body(self.issue) + base = _strip_progress_section(body) + status = _format_status_line(status_line) + bar_text = _format_progress_bar(total_estimate, remaining_estimate, self._progress_start) + updated = _render_progress_section(base, status, bar_text) + self.project.set_issue_body(self.issue, updated) + except Exception as exc: + _logger.warning("Failed to update issue progress for %s", _issue_url(self.issue), exc_info=exc) + + def on_success(self, result): + super().on_success(result) + self.project.ensure_label( + self.issue.repo, + _SUCCESS_LABEL, + color=_SUCCESS_COLOR, + description="Task succeeded", + ) + self.project.add_label(self.issue, _SUCCESS_LABEL) + + def on_failure(self, result): + super().on_failure(result) + self.project.ensure_label( + self.issue.repo, + _FAILURE_LABEL, + color=_FAILURE_COLOR, + description="Task failed", + ) + self.project.add_label(self.issue, _FAILURE_LABEL) + + def tear_down(self): + super().tear_down() + self.project.move(self.issue, "In review") + self.project.release(self.issue) + + +class GhTaskRunner: + def __init__( + self, + project, + name, + task_files, + status="Backlog", + cwd=None, + yolo=True, + flags=None, + ): + task_map = _task_file_map(task_files) + self.project = Project(project, name, has_label=list(task_map)) + self.issue = self.project.take(status=status, return_issue=True) + self.issue = self.project.get_issue(self.issue) + try: + task_path = _match_task_file(self.issue, task_map) + except Exception: + self.project.release(self.issue) + raise + body = self.project.get_issue_body(self.issue) + description = _strip_progress_section(body) + item_text = _format_item_text(self.issue, description) + self.task = GhTaskFile( + task_path, + self.issue, + self.project, + item_text, + cwd, + yolo, + None, + flags, + ) + + def __call__(self, progress=False): + return self.task(progress=progress) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 6972dd7..89c872b 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -370,8 +370,10 @@ def __init__( self._yolo = yolo self._flags = flags self._progress_enabled = False + self._progress_updates = False self._progress_bar = None self._progress_total = None + self._progress_start = None self.agent = Agent( cwd, yolo, @@ -465,8 +467,9 @@ def __call__(self, debug=False, progress=False): # If this fails in the middle we will still try to tear down self.set_up() + progress_updates = progress or self._progress_updates self._progress_enabled = progress - if progress: + if progress_updates: remaining, _summary = estimate( self.prompt, "", @@ -478,6 +481,7 @@ def __call__(self, debug=False, progress=False): ) self._progress_total = remaining start_time = time.monotonic() + self._progress_start = start_time self.on_progress( 0, self.max_iterations, @@ -487,6 +491,7 @@ def __call__(self, debug=False, progress=False): ) else: start_time = time.monotonic() + self._progress_start = start_time # Start with the initial prompt output = self.agent(self.prompt) @@ -503,7 +508,7 @@ def __call__(self, debug=False, progress=False): if debug: _logger.debug("Check error: %s", error) - if progress: + if progress_updates: check_output = self.last_check_output if self.check_skipped: check_output = "Verification skipped." From cad3f59a7c7c73431872556671018f513b5db502 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 15:29:49 +0100 Subject: [PATCH 19/78] Fix gh progress parsing and add taskfile example --- examples/example_task_progress.sh | 5 ++++- examples/task_progress.yaml | 3 +++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/gh_integration.py | 4 ++-- 5 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 examples/task_progress.yaml diff --git a/examples/example_task_progress.sh b/examples/example_task_progress.sh index 7191d38..9a22da4 100755 --- a/examples/example_task_progress.sh +++ b/examples/example_task_progress.sh @@ -1 +1,4 @@ -codexapi task "The goal is to increase /tmp/work_done.txt towards 5 by incrementing it once and then return control to the user/verifier agent for their feedback before doing so again. Step by step is the way. Initialize at 0 if it does not exist. Verifier: report success when the value has reached the target." +#!/bin/bash + +codexapi task "The goal is to increase /tmp/codexapi_work_done.txt towards 5 by incrementing it once and then return control to the user/verifier agent for their feedback before doing so again. Step by step is the way. Initialize at 0 if it does not exist. Verifier: report success when the value has reached the target." +rm -f /tmp/codexapi_work_done.txt diff --git a/examples/task_progress.yaml b/examples/task_progress.yaml new file mode 100644 index 0000000..e2d9be5 --- /dev/null +++ b/examples/task_progress.yaml @@ -0,0 +1,3 @@ +prompt: | + {{item}} + The goal is to increase /tmp/codexapi_work_done.txt towards 5 by incrementing it once and then return control to the user/verifier agent for their feedback before doing so again. Step by step is the way. Initialize at 0 if it does not exist. Verifier: report success when the value has reached the target. diff --git a/pyproject.toml b/pyproject.toml index 36611ea..deb9f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.8" +version = "0.5.9" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 7b1c448..0c13cb2 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.8" +__version__ = "0.5.9" diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index 8f9a8b4..b1c43f9 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -64,7 +64,7 @@ def _match_task_file(issue, task_map): def _strip_progress_section(body): if not body: return "" - match = re.search(r"(?m)^## Progress\\s*$", body) + match = re.search(r"(?m)^## Progress\s*$", body) if not match: return body.strip() return body[:match.start()].rstrip() @@ -78,7 +78,7 @@ def _format_item_text(issue, description): def _format_status_line(status_line): - match = re.match(r"^\\[(?P[^ ]+) @ (?P[^\\]]+)\\]:\\s*(?P.*)$", status_line) + match = re.match(r"^\[(?P[^ ]+) @ (?P[^\]]+)\]:\s*(?P.*)$", status_line) if not match: return status_line summary = match.group("summary").strip() From efaaca7e921cba18d3adbb5b150ac45319585748 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 15:39:45 +0100 Subject: [PATCH 20/78] Default project status to Ready --- README.md | 2 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 4 ++-- src/codexapi/gh_integration.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5051bd5..fb795bc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Checks are wrapped with the verifier prompt, include the agent output, and expec Take tasks from a GitHub Project (requires `gh-task`): ```bash -codexapi task -p owner/projects/3 -n "Your Name" -s Backlog task_a.yaml task_b.yaml +codexapi task -p owner/projects/3 -n "Your Name" -s Ready task_a.yaml task_b.yaml ``` Task labels are derived from task filenames (basename without extension). The diff --git a/pyproject.toml b/pyproject.toml index deb9f64..686eab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.9" +version = "0.5.10" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0c13cb2..206b1fd 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.9" +__version__ = "0.5.10" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f2e5ea7..7453c3c 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1040,8 +1040,8 @@ def main(argv=None): task_parser.add_argument( "-s", "--status", - default="Backlog", - help="Status name to take from when using --project (default: Backlog).", + default="Ready", + help="Status name to take from when using --project (default: Ready).", ) task_parser.add_argument( "-n", diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index b1c43f9..73f0c5a 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -197,7 +197,7 @@ def __init__( project, name, task_files, - status="Backlog", + status="Ready", cwd=None, yolo=True, flags=None, From edac9347a689781a3a9c4b3a569ab0ba82acb921 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 17:53:25 +0100 Subject: [PATCH 21/78] Add task template creator --- README.md | 6 +++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 58 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb795bc..0bb411b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ codexapi task "Fix the failing tests." --max-iterations 5 codexapi task -f task.yaml codexapi task -f task.yaml -i README.md ``` +Create a new task file template: + +```bash +codexapi create task.yaml +codexapi create my_task # adds .yaml +``` Progress is shown by default for `codexapi task`; use `--quiet` to suppress it. When using `--item`, the task file must include at least one `{{item}}` placeholder. diff --git a/pyproject.toml b/pyproject.toml index 686eab6..b31daae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.10" +version = "0.5.11" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 206b1fd..cfc3ac2 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.10" +__version__ = "0.5.11" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 7453c3c..6eba07f 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -40,6 +40,34 @@ "your own best judgement towards our goal! Remember to update SCIENCE.md. " "Good hunting!" ) +_TASK_TEMPLATE = ( + "prompt: |\n" + " Main task prompt. Required. Use {{item}} for per-item values.\n" + " Describe what Codex should do here.\n" + "\n" + "set_up: |\n" + " Optional setup steps before the task runs.\n" + " Example: create a branch for {{item}} and switch to it.\n" + "\n" + "check: |\n" + " Optional verification prompt. Use \"None\" to skip verification.\n" + " If this section is not present, an automatic one based on the prompt will be used.\n" + " Example: run pytest and check all tests pass with no skips or cheats, check README.md updated.\n" + "\n" + "on_success: |\n" + " Optional follow-up instructions after a successful task.\n" + " Example: add and commit changes and use 'gh' to open a PR.\n" + "\n" + "on_failure: |\n" + " Optional follow-up instructions after a failed task.\n" + f" Example: revert changes and abandon the new branch.\n" + "\n" + "tear_down: |\n" + " Optional cleanup steps after the task finishes.\n" + " Example: remove any temporary or untracked files and change back to the main branch.\n" + "\n" + "max_iterations: 10 # Optional (default is 10). 0 means unlimited.\n" +) _TOOL_LABELS = { "apply_patch": "Editing files", "exec_command": "Running command", @@ -87,6 +115,24 @@ def _science_prompt(task): return _SCIENCE_TEMPLATE.replace("{task}", task.strip()) +def _create_task_template(path): + if not isinstance(path, str) or not path.strip(): + raise SystemExit("create requires a filename.") + target = Path(path) + if target.suffix not in (".yaml", ".yml"): + target = Path(f"{target}.yaml") + if target.exists(): + if target.is_dir(): + raise SystemExit(f"{target} is a directory.") + raise SystemExit(f"{target} already exists.") + try: + with open(target, "x", encoding="utf-8") as handle: + handle.write(_TASK_TEMPLATE) + except FileNotFoundError: + raise SystemExit(f"Directory does not exist: {target.parent}") from None + print(target) + + def _truncate_head(text, limit): if limit <= 0: return "" @@ -1229,6 +1275,15 @@ def main(argv=None): help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + create_parser = subparsers.add_parser( + "create", + help="Create a task file template.", + ) + create_parser.add_argument( + "filename", + help="Filename for the new task file.", + ) + subparsers.add_parser( "top", help="Show running Codex sessions.", @@ -1238,6 +1293,9 @@ def main(argv=None): if args.command is None: parser.print_help() raise SystemExit(2) + if args.command == "create": + _create_task_template(args.filename) + return if args.command == "top": _run_top([]) return From 57f7f7eaa988e57571d834c456fc0612ac098eac Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 18:04:33 +0100 Subject: [PATCH 22/78] Clarify task project help --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b31daae..40edb40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.11" +version = "0.5.12" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index cfc3ac2..3441ecb 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.11" +__version__ = "0.5.12" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 6eba07f..473011f 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1081,7 +1081,7 @@ def main(argv=None): task_parser.add_argument( "-p", "--project", - help="GitHub Project reference to pull tasks from.", + help="When using -p, also pass -n agent_name TASK_FILE1 [TASK_FILE2 ...].", ) task_parser.add_argument( "-s", From 7e5e037b9e7d0a47e49cf91685da7d02466c8573 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 18:32:03 +0100 Subject: [PATCH 23/78] Add gh-task dependency and loop mode --- pyproject.toml | 3 +- requirements.txt | 3 ++ src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 70 +++++++++++++++++++++++++++++----------- 4 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 40edb40..3bb9625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.12" +version = "0.5.13" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" @@ -17,6 +17,7 @@ classifiers = [ dependencies = [ "PyYAML>=6.0", + "gh-task", "tqdm>=4.64", ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9fec521 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyYAML>=6.0 +tqdm>=4.64 +gh-task diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 3441ecb..974a6c0 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.12" +__version__ = "0.5.13" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 473011f..20da413 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -6,6 +6,7 @@ import shutil import subprocess import sys +import time import termios import tty from datetime import datetime @@ -23,6 +24,7 @@ _TAIL_BYTES = 256 * 1024 _TAIL_MAX_BYTES = 4 * 1024 * 1024 _TAIL_MIN_LINES = 200 +_PROJECT_LOOP_SLEEP = 30 _ROLL_OUT_PREFIX = "rollout-" _SCIENCE_TEMPLATE = ( "Good afternoon! We have a fun task today - take a good look around this repo " @@ -1128,6 +1130,11 @@ def main(argv=None): action="store_true", help="Suppress progress output during verification.", ) + task_parser.add_argument( + "--loop", + action="store_true", + help="With -p, keep taking tasks and wait when none are available.", + ) ralph_parser = subparsers.add_parser( "ralph", @@ -1363,24 +1370,49 @@ def main(argv=None): raise SystemExit("--name is required with --project.") if not args.task_args: raise SystemExit("task --project requires one or more task files.") - try: - from .gh_integration import GhTaskRunner - except ImportError as exc: - raise SystemExit("gh-task is required for --project. Install it with pip.") from exc - - task_runner = GhTaskRunner( - args.project, - args.name, - args.task_args, - args.status, - args.cwd, - args.yolo, - args.flags, - ) - result = task_runner(progress=not args.quiet) - if not result.success: - raise SystemExit(1) - return + from .gh_integration import GhTaskRunner + from gh_task.errors import TakeError + + if args.loop: + while True: + try: + task_runner = GhTaskRunner( + args.project, + args.name, + args.task_args, + args.status, + args.cwd, + args.yolo, + args.flags, + ) + except TakeError as exc: + print(str(exc), file=sys.stderr) + print( + f"Waiting {_PROJECT_LOOP_SLEEP}s for new tasks...", + file=sys.stderr, + ) + time.sleep(_PROJECT_LOOP_SLEEP) + continue + result = task_runner(progress=not args.quiet) + if not result.success: + raise SystemExit(1) + else: + try: + task_runner = GhTaskRunner( + args.project, + args.name, + args.task_args, + args.status, + args.cwd, + args.yolo, + args.flags, + ) + except TakeError as exc: + raise SystemExit(str(exc)) from None + result = task_runner(progress=not args.quiet) + if not result.success: + raise SystemExit(1) + return if args.command == "task" and args.task_file: if args.task_args: @@ -1449,6 +1481,8 @@ def main(argv=None): if args.command == "task": if args.project: raise SystemExit("task --project already handled earlier.") + if args.loop: + raise SystemExit("--loop is only supported with -p.") if args.item is not None: raise SystemExit("--item is only supported with -f.") if args.max_iterations is None: From 0c6333205c41b114e5484aa3fd323c0957305c3e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 18:49:54 +0100 Subject: [PATCH 24/78] Show project task summary on take --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 12 +++++++++++- src/codexapi/gh_integration.py | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3bb9625..02cbea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.13" +version = "0.5.14" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 974a6c0..d79fd95 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.13" +__version__ = "0.5.14" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 20da413..b445be8 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1370,7 +1370,7 @@ def main(argv=None): raise SystemExit("--name is required with --project.") if not args.task_args: raise SystemExit("task --project requires one or more task files.") - from .gh_integration import GhTaskRunner + from .gh_integration import GhTaskRunner, project_url from gh_task.errors import TakeError if args.loop: @@ -1393,6 +1393,11 @@ def main(argv=None): ) time.sleep(_PROJECT_LOOP_SLEEP) continue + if not args.quiet: + title = task_runner.issue_title or "Untitled issue" + print( + f"Task {task_runner.task_name}: {title} on {project_url(task_runner.project)}" + ) result = task_runner(progress=not args.quiet) if not result.success: raise SystemExit(1) @@ -1409,6 +1414,11 @@ def main(argv=None): ) except TakeError as exc: raise SystemExit(str(exc)) from None + if not args.quiet: + title = task_runner.issue_title or "Untitled issue" + print( + f"Task {task_runner.task_name}: {title} on {project_url(task_runner.project)}" + ) result = task_runner(progress=not args.quiet) if not result.success: raise SystemExit(1) diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index 73f0c5a..4210bf1 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -23,6 +23,25 @@ def _canonical_task_name(path): return Path(path).stem +def project_url(project): + """Return a GitHub URL for the Project board.""" + owner = project.owner + number = project.number + try: + owner_type = project._get_owner_type() + except Exception: + owner_type = None + if owner_type == "organization": + prefix = "orgs" + elif owner_type == "user": + prefix = "users" + else: + prefix = None + if prefix: + return f"https://github.com/{prefix}/{owner}/projects/{number}" + return f"https://github.com/{owner}/projects/{number}" + + def _task_file_map(task_files): mapping = {} for path in task_files: @@ -211,6 +230,8 @@ def __init__( except Exception: self.project.release(self.issue) raise + self.task_name = _canonical_task_name(task_path) + self.issue_title = (self.issue.title or "").strip() body = self.project.get_issue_body(self.issue) description = _strip_progress_section(body) item_text = _format_item_text(self.issue, description) From aab2f403ada283e43ca624705200477c04c97d9b Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 25 Jan 2026 19:08:16 +0100 Subject: [PATCH 25/78] Move project tasks to In progress on take --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/gh_integration.py | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 02cbea3..7e4a4d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.14" +version = "0.5.15" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index d79fd95..6eada0f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.14" +__version__ = "0.5.15" diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index 4210bf1..9699364 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -230,6 +230,11 @@ def __init__( except Exception: self.project.release(self.issue) raise + try: + self.project.move(self.issue, "In progress") + except Exception: + self.project.release(self.issue) + raise self.task_name = _canonical_task_name(task_path) self.issue_title = (self.issue.title or "").strip() body = self.project.get_issue_body(self.issue) From fef0959ef224f1f261baae46f2589410b20d430d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 26 Jan 2026 14:00:59 +0100 Subject: [PATCH 26/78] Reset tasks clears estimate --- pyproject.toml | 4 +-- requirements.txt | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 32 ++++++++++++++++++ src/codexapi/gh_integration.py | 60 +++++++++++++++++++++++++++++++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e4a4d0..5c7c16e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.15" +version = "0.5.16" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" @@ -17,7 +17,7 @@ classifiers = [ dependencies = [ "PyYAML>=6.0", - "gh-task", + "gh-task>=0.1.7", "tqdm>=4.64", ] diff --git a/requirements.txt b/requirements.txt index 9fec521..46aed8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ PyYAML>=6.0 tqdm>=4.64 -gh-task +gh-task>=0.1.7 diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 6eada0f..852a5f0 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.15" +__version__ = "0.5.16" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index b445be8..a2c7163 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1291,6 +1291,29 @@ def main(argv=None): help="Filename for the new task file.", ) + reset_parser = subparsers.add_parser( + "reset", + help="Reset project tasks back to Ready.", + ) + reset_parser.add_argument( + "-p", + "--project", + required=True, + help="GitHub Project ref (owner/projects/3).", + ) + reset_parser.add_argument( + "-n", + "--name", + default="reset", + help="Owner label name for gh-task (default: reset).", + ) + reset_parser.add_argument( + "-d", + "--description", + action="store_true", + help="Remove any Progress section in the issue body.", + ) + subparsers.add_parser( "top", help="Show running Codex sessions.", @@ -1303,6 +1326,15 @@ def main(argv=None): if args.command == "create": _create_task_template(args.filename) return + if args.command == "reset": + from .gh_integration import reset_project_tasks + + issues = reset_project_tasks(args.project, args.name, args.description) + for issue in issues: + title = (issue.title or "Untitled issue").strip() + print(f"{issue.repo}#{issue.number} {title}") + print(f"Reset {len(issues)} task(s).") + return if args.command == "top": _run_top([]) return diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index 9699364..6d5e74d 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -5,7 +5,7 @@ from tqdm import tqdm -from gh_task.project import Project +from gh_task.project import Project, UPDATE_STATUS_MUTATION from .taskfile import TaskFile @@ -17,6 +17,7 @@ _FAILURE_LABEL = "⨉" _SUCCESS_COLOR = "2da44e" _FAILURE_COLOR = "d73a4a" +_OWNER_PREFIX = "owner:" def _canonical_task_name(path): @@ -42,6 +43,63 @@ def project_url(project): return f"https://github.com/{owner}/projects/{number}" +def reset_project_tasks(project, name, description=False): + """Reset owned issues in a project back to Ready.""" + project = Project(project, name) + owner_projects = {} + issues = [] + for status in project.statuses(): + for issue in project.list(status, return_issue=True): + issue = project.get_issue(issue, require_project_item=True) + labels = issue.labels or [] + owner_labels = [label for label in labels if label.lower().startswith(_OWNER_PREFIX)] + if not owner_labels: + continue + issues.append((issue, owner_labels)) + + ready_name, ready_option = project._resolve_status("Ready") + project._ensure_project_loaded() + try: + project._resolve_number_field("Estimate") + estimate_supported = True + except Exception: + estimate_supported = False + + for issue, owner_labels in issues: + owner_name = None + for label in owner_labels: + parts = label.split(":", 1) + if len(parts) == 2 and parts[1].strip(): + owner_name = parts[1].strip() + break + if owner_name and estimate_supported: + owner_project = owner_projects.get(owner_name) + if owner_project is None: + owner_project = Project(project.owner + "/projects/" + str(project.number), owner_name) + owner_projects[owner_name] = owner_project + owner_project.set_estimate(issue, None) + for label in owner_labels: + project._remove_label(issue, label) + project._remove_label(issue, _SUCCESS_LABEL) + project._remove_label(issue, _FAILURE_LABEL) + if (issue.status or "").lower() != ready_name.lower(): + project.client.graphql( + UPDATE_STATUS_MUTATION, + { + "projectId": project._project_id, + "itemId": issue.project_item_id, + "fieldId": project._status_field_id, + "optionId": ready_option, + }, + ) + if description: + body = issue.body if issue.body is not None else project.get_issue_body(issue) + cleaned = _strip_progress_section(body) + if cleaned != body: + project.set_issue_body(issue, cleaned) + return [issue for issue, _labels in issues] + + def _task_file_map(task_files): mapping = {} for path in task_files: From 7511a4e3f57023a92a3a3dffebe6bc807c6418cd Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 26 Jan 2026 14:35:25 +0100 Subject: [PATCH 27/78] Document reset command --- README.md | 6 ++++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bb411b..78add2d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ Take tasks from a GitHub Project (requires `gh-task`): ```bash codexapi task -p owner/projects/3 -n "Your Name" -s Ready task_a.yaml task_b.yaml ``` +Reset owned tasks on a GitHub Project back to Ready: + +```bash +codexapi reset -p owner/projects/3 +codexapi reset -p owner/projects/3 -d # also removes the Progress section +``` Task labels are derived from task filenames (basename without extension). The issue title/body become `{{item}}` after removing any existing `## Progress` diff --git a/pyproject.toml b/pyproject.toml index 5c7c16e..6f120cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.16" +version = "0.5.17" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 852a5f0..2d6550f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -15,4 +15,4 @@ "task", "task_result", ] -__version__ = "0.5.16" +__version__ = "0.5.17" From 7ca92341fa585d70c21971fa34ac312fdb08edf2 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 31 Jan 2026 23:08:12 +0100 Subject: [PATCH 28/78] Refactor ralph loop into class and add science logbook --- src/codexapi/__init__.py | 4 + src/codexapi/cli.py | 35 +--- src/codexapi/ralph.py | 336 ++++++++++++++++++++++----------------- src/codexapi/science.py | 102 ++++++++++++ 4 files changed, 299 insertions(+), 178 deletions(-) create mode 100644 src/codexapi/science.py diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 2d6550f..0dd31c6 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -2,11 +2,15 @@ from .agent import Agent, agent from .foreach import ForeachResult, foreach +from .ralph import Ralph +from .science import Science from .task import Task, TaskFailed, TaskResult, task, task_result __all__ = [ "Agent", "ForeachResult", + "Ralph", + "Science", "Task", "TaskFailed", "TaskResult", diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index a2c7163..7810e1b 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -14,7 +14,8 @@ from .agent import Agent, agent from .foreach import foreach -from .ralph import cancel_ralph_loop, run_ralph_loop +from .ralph import Ralph, cancel_ralph_loop +from .science import Science from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task from .taskfile import TaskFile, load_task_file, task_def_uses_item @@ -26,22 +27,6 @@ _TAIL_MIN_LINES = 200 _PROJECT_LOOP_SLEEP = 30 _ROLL_OUT_PREFIX = "rollout-" -_SCIENCE_TEMPLATE = ( - "Good afternoon! We have a fun task today - take a good look around this repo " - "and review all relevant knowledge you have. Our task is to {task}. We're " - "working step by step in a scientific manner so if there's a SCIENCE.md read " - "that first to understand the progress of the rest of the team so far. Then " - "try as hard as you can to find a good path forwards - run as many experiments " - "as you want and take your time, we have all night. Note down everything you " - "learn that wasn't obvious in a knowledge section in SCIENCE.md and any " - "experiments in a similar section. The aim is to move the ball forwards, " - "either by getting closer to the goal ruling out a hypothesis that doesn't " - "whilst understanding why. Try your best and have fun with this one! If you " - "think of several options, pick one and run with it - I will not be available " - "to make decisions for you, I give you my full permission to explore and make " - "your own best judgement towards our goal! Remember to update SCIENCE.md. " - "Good hunting!" -) _TASK_TEMPLATE = ( "prompt: |\n" " Main task prompt. Required. Use {{item}} for per-item values.\n" @@ -111,11 +96,6 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) -def _science_prompt(task): - if not isinstance(task, str) or not task.strip(): - raise SystemExit("Science task must be a non-empty string.") - return _SCIENCE_TEMPLATE.replace("{task}", task.strip()) - def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -1496,7 +1476,7 @@ def main(argv=None): if args.command == "ralph": if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") - run_ralph_loop( + Ralph( prompt, args.cwd, args.yolo, @@ -1504,21 +1484,20 @@ def main(argv=None): args.max_iterations, args.completion_promise, args.ralph_fresh, - ) + )() return if args.command == "science": if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") - science_prompt = _science_prompt(prompt) - run_ralph_loop( - science_prompt, + Science( + prompt, args.cwd, args.yolo, args.flags, args.max_iterations, args.completion_promise, args.ralph_fresh, - ) + )() return if args.command == "task": if args.project: diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index a557980..e6a5d10 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -12,174 +12,210 @@ _PROMISE_RE = re.compile(r"(.*?)", re.DOTALL) -def run_ralph_loop( - prompt, - cwd=None, - yolo=True, - flags=None, - max_iterations=0, - completion_promise=None, - fresh=True, -): - """Run a Ralph Wiggum-style loop that repeats the same prompt. - - The loop writes `.codexapi/ralph-loop.local.md` in the target cwd and keeps - sending the exact same prompt each iteration until one of these happens: - - A completion promise is matched. - - `max_iterations` is reached (0 means unlimited). - - The state file is removed (cancel). - - An error or KeyboardInterrupt. - - To complete with a promise, the agent must output: - TEXT - `TEXT` is trimmed and whitespace-collapsed before an exact match against - `completion_promise`. CRITICAL RULE: If a completion promise is set, you - may ONLY output it when the statement is completely and unequivocally TRUE. - Do not output false promises to escape the loop. - - By default each iteration uses a fresh Agent for a clean context. Set - `fresh=False` to reuse a single Agent instance for shared context. - Cancel by deleting the state file or running `codexapi ralph --cancel`. - """ - if not isinstance(prompt, str) or not prompt.strip(): - raise ValueError("prompt must be a non-empty string") - if completion_promise is not None and not isinstance(completion_promise, str): - raise TypeError("completion_promise must be a string or None") - if max_iterations < 0: - raise ValueError("max_iterations must be >= 0") - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(line_buffering=True) +class Ralph: + """Ralph Wiggum-style loop runner for repeating the same prompt.""" - state_path = _state_path(cwd) - _ensure_state_dir(state_path) - - started_at = _utc_now() - iteration = 1 - _write_state( - state_path, - iteration, - max_iterations, - completion_promise, - started_at, + def __init__( + self, prompt, - ) - - max_label = str(max_iterations) if max_iterations > 0 else "unlimited" - if completion_promise is None: - promise_label = "none (runs forever)" - else: - promise_label = ( - f"{completion_promise} (ONLY output when TRUE - do not lie!)" + cwd=None, + yolo=True, + flags=None, + max_iterations=0, + completion_promise=None, + fresh=True, + ): + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + if completion_promise is not None and not isinstance( + completion_promise, + str, + ): + raise TypeError("completion_promise must be a string or None") + if max_iterations < 0: + raise ValueError("max_iterations must be >= 0") + self.prompt = prompt + self.cwd = cwd + self.yolo = yolo + self.flags = flags + self.max_iterations = max_iterations + self.completion_promise = completion_promise + self.fresh = fresh + + def hook_before_loop(self): + """Hook called once before the loop starts.""" + + def hook_before_iteration(self, iteration): + """Hook called before each iteration.""" + + def hook_after_iteration(self, iteration, message): + """Hook called after each iteration completes.""" + + def hook_after_loop(self, last_message, stop_reason): + """Hook called after the loop exits.""" + + def build_prompt(self, iteration): + """Return the prompt for this iteration.""" + return self.prompt + + def __call__(self): + """Run the loop until completion or cancellation.""" + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + + state_path = _state_path(self.cwd) + _ensure_state_dir(state_path) + + started_at = _utc_now() + iteration = 1 + _write_state( + state_path, + iteration, + self.max_iterations, + self.completion_promise, + started_at, + self.prompt, ) - print( - "\n".join( - [ - "Ralph loop activated.", - "", - f"Iteration: {iteration}", - f"Max iterations: {max_label}", - f"Completion promise: {promise_label}", - "", - "The loop will resend the SAME PROMPT each iteration.", - "Cancel by deleting .codexapi/ralph-loop.local.md or running", - "codexapi ralph --cancel.", - "No manual stop beyond max iterations or completion promise.", - "", - "To monitor: head -10 .codexapi/ralph-loop.local.md", - "", - ] + max_label = ( + str(self.max_iterations) if self.max_iterations > 0 else "unlimited" ) - ) - print(prompt) + if self.completion_promise is None: + promise_label = "none (runs forever)" + else: + promise_label = ( + f"{self.completion_promise} (ONLY output when TRUE - do not lie!)" + ) - if completion_promise is not None: print( "\n".join( [ + "Ralph loop activated.", "", - "CRITICAL - Ralph Loop Completion Promise", + f"Iteration: {iteration}", + f"Max iterations: {max_label}", + f"Completion promise: {promise_label}", "", - "To complete this loop, output this EXACT text:", - f" {completion_promise}", + "The loop will resend the SAME PROMPT each iteration.", + "Cancel by deleting .codexapi/ralph-loop.local.md or running", + "codexapi ralph --cancel.", + "No manual stop beyond max iterations or completion promise.", "", - "STRICT REQUIREMENTS (DO NOT VIOLATE):", - " - Use XML tags EXACTLY as shown above", - " - The statement MUST be completely and unequivocally TRUE", - " - Do NOT output false statements to exit the loop", - " - Do NOT lie even if you think you should exit", - "", - "CRITICAL RULE: If a completion promise is set, you may ONLY", - "output it when the statement is completely and unequivocally", - "TRUE. Do not output false promises to escape the loop, even if", - "you think you're stuck or should exit for other reasons. The", - "loop is designed to continue until genuine completion.", + "To monitor: head -10 .codexapi/ralph-loop.local.md", "", ] ) ) - - runner = None - last_message = None - state_missing = False - - try: - while True: - if not os.path.exists(state_path): - state_missing = True - print("Ralph loop canceled: state file removed.") - return last_message - - print(_status_line(iteration, completion_promise)) - - if fresh: - runner = Agent(cwd, yolo, None, flags) - elif runner is None: - runner = Agent(cwd, yolo, None, flags) - - message = runner(prompt + '\nIf there are multiple paths forward, you MUST use your own best judgement as to which to try first! Do not ask the user to choose an option, they hereby give you explciit permission to pick the best one yourself.\n') - print(message) - last_message = message - - if not os.path.exists(state_path): - state_missing = True - print("Ralph loop canceled: state file removed.") - return last_message - - if max_iterations > 0 and iteration >= max_iterations: - print(f"Ralph loop: Max iterations ({max_iterations}) reached.") - return message - - if promise_matches(message, completion_promise): - print( - "Ralph loop: Detected " - f"{completion_promise}" + print(self.prompt) + + if self.completion_promise is not None: + print( + "\n".join( + [ + "", + "CRITICAL - Ralph Loop Completion Promise", + "", + "To complete this loop, output this EXACT text:", + f" {self.completion_promise}", + "", + "STRICT REQUIREMENTS (DO NOT VIOLATE):", + " - Use XML tags EXACTLY as shown above", + " - The statement MUST be completely and unequivocally TRUE", + " - Do NOT output false statements to exit the loop", + " - Do NOT lie even if you think you should exit", + "", + "CRITICAL RULE: If a completion promise is set, you may ONLY", + "output it when the statement is completely and unequivocally", + "TRUE. Do not output false promises to escape the loop, even if", + "you think you're stuck or should exit for other reasons. The", + "loop is designed to continue until genuine completion.", + "", + ] ) - return message - - if not os.path.exists(state_path): - state_missing = True - print("Ralph loop canceled: state file removed.") - return last_message - - iteration += 1 - _write_state( - state_path, - iteration, - max_iterations, - completion_promise, - started_at, - prompt, ) - except KeyboardInterrupt: - print("Ralph loop interrupted.", file=sys.stderr) - raise SystemExit(130) - except Exception as exc: - print(f"Ralph loop stopped: {exc}", file=sys.stderr) - raise SystemExit(1) - finally: - if not state_missing: - _cleanup_state(state_path) + + runner = None + last_message = None + state_missing = False + stop_reason = None + + try: + self.hook_before_loop() + while True: + if not os.path.exists(state_path): + state_missing = True + stop_reason = "canceled" + print("Ralph loop canceled: state file removed.") + return last_message + + print(_status_line(iteration, self.completion_promise)) + self.hook_before_iteration(iteration) + + if self.fresh: + runner = Agent(self.cwd, self.yolo, None, self.flags) + elif runner is None: + runner = Agent(self.cwd, self.yolo, None, self.flags) + + prompt = self.build_prompt(iteration) + message = runner( + prompt + + "\nIf there are multiple paths forward, you MUST use your " + "own best judgement as to which to try first! Do not ask the " + "user to choose an option, they hereby give you explciit " + "permission to pick the best one yourself.\n" + ) + print(message) + last_message = message + self.hook_after_iteration(iteration, message) + + if not os.path.exists(state_path): + state_missing = True + stop_reason = "canceled" + print("Ralph loop canceled: state file removed.") + return last_message + + if self.max_iterations > 0 and iteration >= self.max_iterations: + stop_reason = "max_iterations" + print( + f"Ralph loop: Max iterations ({self.max_iterations}) reached." + ) + return message + + if promise_matches(message, self.completion_promise): + stop_reason = "promise" + print( + "Ralph loop: Detected " + f"{self.completion_promise}" + ) + return message + + if not os.path.exists(state_path): + state_missing = True + stop_reason = "canceled" + print("Ralph loop canceled: state file removed.") + return last_message + + iteration += 1 + _write_state( + state_path, + iteration, + self.max_iterations, + self.completion_promise, + started_at, + self.prompt, + ) + except KeyboardInterrupt: + stop_reason = "interrupted" + print("Ralph loop interrupted.", file=sys.stderr) + raise SystemExit(130) + except Exception as exc: + stop_reason = "error" + print(f"Ralph loop stopped: {exc}", file=sys.stderr) + raise SystemExit(1) + finally: + if not state_missing: + _cleanup_state(state_path) + self.hook_after_loop(last_message, stop_reason) def cancel_ralph_loop(cwd=None): diff --git a/src/codexapi/science.py b/src/codexapi/science.py new file mode 100644 index 0000000..d6bee29 --- /dev/null +++ b/src/codexapi/science.py @@ -0,0 +1,102 @@ +"""Science-mode Ralph loop with logbook output.""" + +import os +from datetime import datetime, timezone + +from .ralph import Ralph + +_SCIENCE_TEMPLATE_A = ( + "Good afternoon! We have a fun task today - take a good look around this repo " + "and review all relevant knowledge you have. Our task is to {task}. We're " + "working step by step in a scientific manner so if there's a SCIENCE.md read " + "that first to understand the progress of the rest of the team so far. Then " + "try as hard as you can to find a good path forwards - run as many experiments " + "as you want and take your time, we have all night. Note down everything you " + "learn that wasn't obvious in a knowledge section in SCIENCE.md and any " + "experiments in a similar section. The aim is to move the ball forwards, " + "either by getting closer to the goal ruling out a hypothesis that doesn't " + "whilst understanding why. " +) +_SCIENCE_TEMPLATE_B = ( + "Try your best and have fun with this one! If you " + "think of several options, pick one and run with it - I will not be available " + "to make decisions for you, I give you my full permission to explore and make " + "your own best judgement towards our goal! Remember to update SCIENCE.md. " + "Good hunting!" +) +_LOGBOOK_NAME = "LOGBOOK.md" + + +def _science_parts(task): + if not isinstance(task, str) or not task.strip(): + raise ValueError("Science task must be a non-empty string.") + task = task.strip() + return _SCIENCE_TEMPLATE_A.replace("{task}", task), _SCIENCE_TEMPLATE_B + + +def _science_prompt(task): + part_a, part_b = _science_parts(task) + return f"{part_a}{part_b}" + + +def _logbook_path(cwd): + root = os.fspath(cwd) if cwd else os.getcwd() + return os.path.join(root, _LOGBOOK_NAME) + + +def _iteration_note(iteration): + return ( + f"We are now in iteration {iteration}. Before deciding on your next steps, " + "review LOGBOOK.md to see what was done and proposed in previous iterations. " + "Treat all questions in there as suggestions only. You may decide it's time " + "to try a completely different tack, or you may see something that feels like " + "a follow-up that should be investigated. I trust your good judgement! Do not " + "write to LOGBOOK.md, it will be updated automatically when we have finished." + ) + + +class Science(Ralph): + """Science-mode Ralph runner that logs each iteration output.""" + + def __init__( + self, + task, + cwd=None, + yolo=True, + flags=None, + max_iterations=0, + completion_promise=None, + fresh=True, + ): + prompt_a, prompt_b = _science_parts(task) + prompt = f"{prompt_a}{prompt_b}" + super().__init__( + prompt, + cwd, + yolo, + flags, + max_iterations, + completion_promise, + fresh, + ) + self._prompt_a = prompt_a + self._prompt_b = prompt_b + self._logbook_path = _logbook_path(cwd) + + def build_prompt(self, iteration): + if iteration <= 1: + return f"{self._prompt_a}{self._prompt_b}" + note = _iteration_note(iteration) + return f"{self._prompt_a}\n\n{note}\n\n{self._prompt_b}" + + def hook_after_iteration(self, iteration, message): + super().hook_after_iteration(iteration, message) + self._append_logbook(iteration, message) + + def _append_logbook(self, iteration, message): + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + header = f"## Iteration {iteration} - {timestamp}" + body = (message or "").rstrip() + entry = "\n".join([header, "", body, "", ""]) + with open(self._logbook_path, "a", encoding="utf-8") as handle: + handle.write(entry) From 9ad46dbde04260abfcd3dc30c55f74c7f21fdef5 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 31 Jan 2026 23:47:23 +0100 Subject: [PATCH 29/78] Add science metrics extraction and pushover notes --- README.md | 7 + src/codexapi/ralph.py | 3 + src/codexapi/science.py | 324 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 328 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 78add2d..71153a0 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,19 @@ codexapi ralph --cancel --cwd /path/to/project Science mode wraps a short task in a science prompt and runs it through the Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. +Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts +any improved figures of merit for optional notifications. ```bash codexapi science "hyper-optimize the kernel cycles" codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3 ``` +Optional Pushover notifications: create `~/.pushover` with two non-empty lines. +Line 1 is your user or group key, line 2 is the app API token. When this file +exists, Science will send a notification whenever it detects a new best result, +including the metric values and percent improvement. + Run a task file across a list file: ```bash diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index e6a5d10..267594d 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -54,6 +54,9 @@ def hook_after_iteration(self, iteration, message): def hook_after_loop(self, last_message, stop_reason): """Hook called after the loop exits.""" + def hook_new_best(self, result): + """Hook called when a new best result is detected.""" + def build_prompt(self, iteration): """Return the prompt for this iteration.""" return self.prompt diff --git a/src/codexapi/science.py b/src/codexapi/science.py index d6bee29..dc0ea6f 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -1,8 +1,14 @@ -"""Science-mode Ralph loop with logbook output.""" +"""Science-mode Ralph loop with logbook output and metric notifications.""" +import json import os +import sys +import urllib.error +import urllib.parse +import urllib.request from datetime import datetime, timezone +from .agent import agent from .ralph import Ralph _SCIENCE_TEMPLATE_A = ( @@ -18,6 +24,10 @@ "whilst understanding why. " ) _SCIENCE_TEMPLATE_B = ( + "If this task has some natural figure of merit that would demonstrate any " + "improvements we made, mention each improvement you have made to it and what " + "the new best figures are (and each one's percentage improvement over the baseline) " + "when you are finished and report back to me. " "Try your best and have fun with this one! If you " "think of several options, pick one and run with it - I will not be available " "to make decisions for you, I give you my full permission to explore and make " @@ -25,6 +35,36 @@ "Good hunting!" ) _LOGBOOK_NAME = "LOGBOOK.md" +_PUSHOVER_PATH = "~/.pushover" +_PUSHOVER_URL = "https://api.pushover.net/1/messages.json" +_MAX_PUSHOVER_MESSAGE = 1024 + +_TITLE_PROMPT = ( + "You are naming a run. Return a short descriptive title (max 6 words) that " + "is likely to be unique for this task. Return only the title text, no quotes, " + "no punctuation, no markdown. Do not run commands or modify files." +) +_METRICS_PROMPT = ( + "You are a metrics extraction agent. Do NOT attempt the task, do not run " + "commands, and do not propose next steps. Your job is to read the task and " + "agent output and extract improved figures of merit.\n" + "\n" + "Set new_improvement to true when any figure of merit improved and no other " + "important metrics meaningfully regress. Use your judgement for " + "'meaningfully'. If there are no clear metrics or no improvements, set " + "new_improvement to false.\n" + "For each metric listed, look to see if there is also a percentage improvement " + "associated with it - if so, include that under improvement_pct in your output." + "\n" + "Return ONLY JSON with keys:\n" + " new_improvement: boolean\n" + " summary: string (single sentence)\n" + " metrics: list of objects with keys:\n" + " name: string\n" + " value: string (absolute value)\n" + " improvement_pct: number or null (percent vs baseline)\n" + "\n" +) def _science_parts(task): @@ -34,11 +74,6 @@ def _science_parts(task): return _SCIENCE_TEMPLATE_A.replace("{task}", task), _SCIENCE_TEMPLATE_B -def _science_prompt(task): - part_a, part_b = _science_parts(task) - return f"{part_a}{part_b}" - - def _logbook_path(cwd): root = os.fspath(cwd) if cwd else os.getcwd() return os.path.join(root, _LOGBOOK_NAME) @@ -68,6 +103,7 @@ def __init__( completion_promise=None, fresh=True, ): + self._task = task.strip() if isinstance(task, str) else task prompt_a, prompt_b = _science_parts(task) prompt = f"{prompt_a}{prompt_b}" super().__init__( @@ -82,6 +118,15 @@ def __init__( self._prompt_a = prompt_a self._prompt_b = prompt_b self._logbook_path = _logbook_path(cwd) + self._best_metrics = None + self._run_title = None + self._pushover_tokens = None + self._pushover_checked = False + self._pushover_error = None + + def hook_before_loop(self): + super().hook_before_loop() + self._run_title = self._build_run_title() def build_prompt(self, iteration): if iteration <= 1: @@ -92,6 +137,17 @@ def build_prompt(self, iteration): def hook_after_iteration(self, iteration, message): super().hook_after_iteration(iteration, message) self._append_logbook(iteration, message) + self._extract_and_notify(message) + + def hook_new_best(self, result): + super().hook_new_best(result) + summary = _single_line(result.get("summary", "")).strip() + metrics = result.get("metrics") or [] + message = _format_notification_message(summary, metrics) + if not message: + message = "New best metrics detected." + print(message) + self._send_pushover(self._run_title, message) def _append_logbook(self, iteration, message): timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -100,3 +156,259 @@ def _append_logbook(self, iteration, message): entry = "\n".join([header, "", body, "", ""]) with open(self._logbook_path, "a", encoding="utf-8") as handle: handle.write(entry) + + def _extract_and_notify(self, message): + prompt = _build_metrics_prompt(self._task, message, self._best_metrics) + try: + output = agent(prompt, self.cwd, self.yolo, self.flags) + except Exception as exc: + _warn(f"Metrics extraction failed: {exc}") + return + try: + result = _parse_metrics(output) + except ValueError as exc: + _warn(f"Metrics extraction returned invalid JSON: {exc}") + return + if result.get("new_improvement"): + self._best_metrics = result + self.hook_new_best(result) + + def _build_run_title(self): + prompt = "\n".join( + [ + _TITLE_PROMPT, + "", + "TASK:", + str(self._task or "").strip(), + ] + ) + try: + title = agent(prompt, self.cwd, self.yolo, self.flags) + except Exception: + title = "" + title = _single_line(title).strip() + if not title: + title = _fallback_title(self._task) + return title + + def _send_pushover(self, title, message): + tokens = self._get_pushover_tokens() + if not tokens: + return + user_key, app_token = tokens + message = _truncate(message, _MAX_PUSHOVER_MESSAGE) + payload = urllib.parse.urlencode( + { + "token": app_token, + "user": user_key, + "title": title or "Science update", + "message": message, + } + ).encode("utf-8") + request = urllib.request.Request(_PUSHOVER_URL, data=payload) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + _report_pushover_error(body, exc.code) + return + except Exception as exc: + _warn(f"Pushover notification failed: {exc}") + return + try: + data = json.loads(body) + except json.JSONDecodeError: + _warn("Pushover returned invalid JSON.") + return + if data.get("status") != 1: + _report_pushover_error(body, None) + + def _get_pushover_tokens(self): + if self._pushover_checked: + return self._pushover_tokens + self._pushover_checked = True + try: + tokens = _load_pushover_tokens() + except ValueError as exc: + self._pushover_error = f"Pushover config error: {exc}" + _warn(self._pushover_error) + return None + self._pushover_tokens = tokens + return tokens + + +def _build_metrics_prompt(task, message, previous_best): + best_text = "None" + if previous_best is not None: + best_text = json.dumps(previous_best, indent=2, sort_keys=True) + return "\n".join( + [ + _METRICS_PROMPT, + "", + "TASK (context only, do not attempt it):", + str(task or "").strip(), + "", + "PREVIOUS BEST METRICS:", + best_text, + "", + "AGENT OUTPUT:", + str(message or "").strip(), + ] + ).strip() + + +def _parse_metrics(output): + try: + data = json.loads(output) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON ({exc})") from exc + if not isinstance(data, dict): + raise ValueError("JSON must be an object") + new_improvement = data.get("new_improvement") + summary = data.get("summary") + metrics = data.get("metrics") + if not isinstance(new_improvement, bool): + raise ValueError("missing boolean 'new_improvement'") + if not isinstance(summary, str): + raise ValueError("missing string 'summary'") + if not isinstance(metrics, list): + raise ValueError("metrics must be a list") + cleaned_metrics = [] + for item in metrics: + if not isinstance(item, dict): + raise ValueError("metrics entries must be objects") + name = item.get("name") + value = item.get("value") + improvement_pct = item.get("improvement_pct") + if not isinstance(name, str) or not name.strip(): + raise ValueError("metric name must be a non-empty string") + if not isinstance(value, str) or not value.strip(): + raise ValueError("metric value must be a non-empty string") + if improvement_pct is not None and not isinstance( + improvement_pct, + (int, float), + ): + raise ValueError("metric improvement_pct must be a number or null") + cleaned_metrics.append( + { + "name": name.strip(), + "value": value.strip(), + "improvement_pct": improvement_pct, + } + ) + return { + "new_improvement": new_improvement, + "summary": _single_line(summary), + "metrics": cleaned_metrics, + } + + +def _load_pushover_tokens(): + path = os.path.expanduser(_PUSHOVER_PATH) + if not os.path.exists(path): + return None + with open(path, "r", encoding="utf-8") as handle: + lines = [line.strip() for line in handle if line.strip()] + if len(lines) != 2: + raise ValueError( + f"{_PUSHOVER_PATH} must contain two non-empty lines: user key then app token" + ) + return lines[0], lines[1] + + +def _report_pushover_error(body, status_code): + errors = None + try: + data = json.loads(body) + if isinstance(data, dict): + errors = data.get("errors") + except json.JSONDecodeError: + errors = None + message = "Pushover notification failed." + if status_code: + message = f"{message} HTTP {status_code}." + detail = _format_pushover_errors(errors) + if detail: + message = f"{message} {detail}" + _warn(message) + + +def _format_pushover_errors(errors): + if not errors: + return "" + if isinstance(errors, str): + errors = [errors] + if not isinstance(errors, list): + return "" + cleaned = [str(error).strip() for error in errors if str(error).strip()] + if not cleaned: + return "" + hint = [] + lower = " ".join(cleaned).lower() + if "user" in lower: + hint.append("Check the user key on line 1 of ~/.pushover.") + if "token" in lower or "application" in lower: + hint.append("Check the app token on line 2 of ~/.pushover.") + if "message" in lower: + hint.append("Check that the message is not empty or too long.") + suffix = " ".join(hint) + if suffix: + return f"{'; '.join(cleaned)} {suffix}" + return "; ".join(cleaned) + + +def _format_notification_message(summary, metrics): + parts = [] + metrics_text = _format_metrics(metrics) + if metrics_text: + parts.append(f"New best: {metrics_text}") + if summary: + parts.append(summary) + return "\n".join(parts).strip() + + +def _format_metrics(metrics): + if not metrics: + return "" + rendered = [] + for item in metrics: + if not isinstance(item, dict): + continue + name = _single_line(item.get("name", "")).strip() + value = _single_line(item.get("value", "")).strip() + improvement = item.get("improvement_pct") + if not name or not value: + continue + if isinstance(improvement, (int, float)): + rendered.append(f"{name}={value} ({improvement:+.2f}%)") + else: + rendered.append(f"{name}={value}") + return "; ".join(rendered) + + +def _single_line(text): + if not text: + return "" + return " ".join(text.replace("\r", " ").split()) + + +def _truncate(text, limit): + if not text: + return "" + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return text[: limit - 3] + "..." + + +def _fallback_title(task): + text = _single_line(task or "").strip() + if not text: + return "Science run" + return _truncate(text, 80) + + +def _warn(message): + print(message, file=sys.stderr) From a410a733a0d7810c9ff8450d7b3518502e8874b0 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 31 Jan 2026 23:58:48 +0100 Subject: [PATCH 30/78] Add pushover notifier and bump version to 0.6.0 --- README.md | 3 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 4 +- src/codexapi/pushover.py | 179 +++++++++++++++++++++++++++++++++++++++ src/codexapi/science.py | 131 ++-------------------------- src/codexapi/task.py | 30 +++++++ 6 files changed, 224 insertions(+), 125 deletions(-) create mode 100644 src/codexapi/pushover.py diff --git a/README.md b/README.md index 71153a0..c5a3596 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3 Optional Pushover notifications: create `~/.pushover` with two non-empty lines. Line 1 is your user or group key, line 2 is the app API token. When this file exists, Science will send a notification whenever it detects a new best result, -including the metric values and percent improvement. +including the metric values and percent improvement. Task runs will also send a +✅/❌ notification with the task summary. Run a task file across a list file: diff --git a/pyproject.toml b/pyproject.toml index 6f120cd..4e83ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.5.17" +version = "0.6.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0dd31c6..99a7e12 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -2,6 +2,7 @@ from .agent import Agent, agent from .foreach import ForeachResult, foreach +from .pushover import Pushover from .ralph import Ralph from .science import Science from .task import Task, TaskFailed, TaskResult, task, task_result @@ -9,6 +10,7 @@ __all__ = [ "Agent", "ForeachResult", + "Pushover", "Ralph", "Science", "Task", @@ -19,4 +21,4 @@ "task", "task_result", ] -__version__ = "0.5.17" +__version__ = "0.6.0" diff --git a/src/codexapi/pushover.py b/src/codexapi/pushover.py new file mode 100644 index 0000000..3f584b7 --- /dev/null +++ b/src/codexapi/pushover.py @@ -0,0 +1,179 @@ +"""Pushover notification helper.""" + +import json +import os +import sys +import threading +import urllib.error +import urllib.parse +import urllib.request + +_PUSHOVER_PATH = "~/.pushover" +_PUSHOVER_URL = "https://api.pushover.net/1/messages.json" +_MAX_MESSAGE = 1024 + +_STARTUP_MESSAGE = ( + "Pushover user and app keys read, notifications for task and science enabled." +) + + +class Pushover: + """Send Pushover notifications when configured.""" + + _lock = threading.Lock() + _state = {} + + def __init__(self, path=_PUSHOVER_PATH): + self.path = os.path.expanduser(path) + self._state_ref = self._state_for_path(self.path) + + def ensure_ready(self, announce=True): + state = self._state_ref + with self._lock: + if not state["checked"]: + state["checked"] = True + if not os.path.exists(self.path): + state["enabled"] = False + return False + try: + tokens = _load_pushover_tokens(self.path) + except ValueError as exc: + state["error"] = f"Pushover config error: {exc}" + raise SystemExit(state["error"]) from None + state["tokens"] = tokens + state["enabled"] = True + if state["error"]: + raise SystemExit(state["error"]) from None + if announce and state["enabled"] and not state["announced"]: + print(_STARTUP_MESSAGE) + state["announced"] = True + return state["enabled"] + + def send(self, title, message): + tokens = self._get_tokens() + if not tokens: + return False + user_key, app_token = tokens + title_text = _single_line(title).strip() or "Codex update" + message_text = (message or "").strip() + if not message_text: + return False + message_text = _truncate(message_text, _MAX_MESSAGE) + payload = urllib.parse.urlencode( + { + "token": app_token, + "user": user_key, + "title": title_text, + "message": message_text, + } + ).encode("utf-8") + request = urllib.request.Request(_PUSHOVER_URL, data=payload) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + _report_pushover_error(body, exc.code) + return False + except Exception as exc: + _warn(f"Pushover notification failed: {exc}") + return False + try: + data = json.loads(body) + except json.JSONDecodeError: + _warn("Pushover returned invalid JSON.") + return False + if data.get("status") != 1: + _report_pushover_error(body, None) + return False + return True + + def _get_tokens(self): + if not self.ensure_ready(announce=False): + return None + return self._state_ref["tokens"] + + @classmethod + def _state_for_path(cls, path): + state = cls._state.get(path) + if state is None: + state = { + "checked": False, + "enabled": False, + "tokens": None, + "error": None, + "announced": False, + } + cls._state[path] = state + return state + + +def _load_pushover_tokens(path): + with open(path, "r", encoding="utf-8") as handle: + lines = [line.strip() for line in handle if line.strip()] + if len(lines) != 2: + raise ValueError( + f"{path} must contain two non-empty lines: user key then app token" + ) + return lines[0], lines[1] + + +def _report_pushover_error(body, status_code): + errors = None + try: + data = json.loads(body) + if isinstance(data, dict): + errors = data.get("errors") + except json.JSONDecodeError: + errors = None + message = "Pushover notification failed." + if status_code: + message = f"{message} HTTP {status_code}." + detail = _format_pushover_errors(errors) + if detail: + message = f"{message} {detail}" + _warn(message) + + +def _format_pushover_errors(errors): + if not errors: + return "" + if isinstance(errors, str): + errors = [errors] + if not isinstance(errors, list): + return "" + cleaned = [str(error).strip() for error in errors if str(error).strip()] + if not cleaned: + return "" + hint = [] + lower = " ".join(cleaned).lower() + if "user" in lower: + hint.append("Check the user key on line 1 of ~/.pushover.") + if "token" in lower or "application" in lower: + hint.append("Check the app token on line 2 of ~/.pushover.") + if "message" in lower: + hint.append("Check that the message is not empty or too long.") + suffix = " ".join(hint) + if suffix: + return f"{'; '.join(cleaned)} {suffix}" + return "; ".join(cleaned) + + +def _single_line(text): + if not text: + return "" + return " ".join(str(text).replace("\r", " ").split()) + + +def _truncate(text, limit): + if not text: + return "" + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return text[: limit - 3] + "..." + + +def _warn(message): + print(message, file=sys.stderr) diff --git a/src/codexapi/science.py b/src/codexapi/science.py index dc0ea6f..02a52a1 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -3,12 +3,10 @@ import json import os import sys -import urllib.error -import urllib.parse -import urllib.request from datetime import datetime, timezone from .agent import agent +from .pushover import Pushover from .ralph import Ralph _SCIENCE_TEMPLATE_A = ( @@ -26,8 +24,8 @@ _SCIENCE_TEMPLATE_B = ( "If this task has some natural figure of merit that would demonstrate any " "improvements we made, mention each improvement you have made to it and what " - "the new best figures are (and each one's percentage improvement over the baseline) " - "when you are finished and report back to me. " + "the new best figures are (with absolute values and each one's percentage improvement " + "over the baseline) when you are finished and report back to me. " "Try your best and have fun with this one! If you " "think of several options, pick one and run with it - I will not be available " "to make decisions for you, I give you my full permission to explore and make " @@ -35,10 +33,6 @@ "Good hunting!" ) _LOGBOOK_NAME = "LOGBOOK.md" -_PUSHOVER_PATH = "~/.pushover" -_PUSHOVER_URL = "https://api.pushover.net/1/messages.json" -_MAX_PUSHOVER_MESSAGE = 1024 - _TITLE_PROMPT = ( "You are naming a run. Return a short descriptive title (max 6 words) that " "is likely to be unique for this task. Return only the title text, no quotes, " @@ -54,7 +48,8 @@ "'meaningfully'. If there are no clear metrics or no improvements, set " "new_improvement to false.\n" "For each metric listed, look to see if there is also a percentage improvement " - "associated with it - if so, include that under improvement_pct in your output." + "associated with it - if so, include that under improvement_pct in your output. " + "Always include the absolute value under value." "\n" "Return ONLY JSON with keys:\n" " new_improvement: boolean\n" @@ -120,12 +115,11 @@ def __init__( self._logbook_path = _logbook_path(cwd) self._best_metrics = None self._run_title = None - self._pushover_tokens = None - self._pushover_checked = False - self._pushover_error = None + self._pushover = Pushover() def hook_before_loop(self): super().hook_before_loop() + self._pushover.ensure_ready() self._run_title = self._build_run_title() def build_prompt(self, iteration): @@ -147,7 +141,7 @@ def hook_new_best(self, result): if not message: message = "New best metrics detected." print(message) - self._send_pushover(self._run_title, message) + self._pushover.send(self._run_title, message) def _append_logbook(self, iteration, message): timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -191,51 +185,6 @@ def _build_run_title(self): title = _fallback_title(self._task) return title - def _send_pushover(self, title, message): - tokens = self._get_pushover_tokens() - if not tokens: - return - user_key, app_token = tokens - message = _truncate(message, _MAX_PUSHOVER_MESSAGE) - payload = urllib.parse.urlencode( - { - "token": app_token, - "user": user_key, - "title": title or "Science update", - "message": message, - } - ).encode("utf-8") - request = urllib.request.Request(_PUSHOVER_URL, data=payload) - try: - with urllib.request.urlopen(request, timeout=10) as response: - body = response.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as exc: - body = exc.read().decode("utf-8", errors="replace") - _report_pushover_error(body, exc.code) - return - except Exception as exc: - _warn(f"Pushover notification failed: {exc}") - return - try: - data = json.loads(body) - except json.JSONDecodeError: - _warn("Pushover returned invalid JSON.") - return - if data.get("status") != 1: - _report_pushover_error(body, None) - - def _get_pushover_tokens(self): - if self._pushover_checked: - return self._pushover_tokens - self._pushover_checked = True - try: - tokens = _load_pushover_tokens() - except ValueError as exc: - self._pushover_error = f"Pushover config error: {exc}" - _warn(self._pushover_error) - return None - self._pushover_tokens = tokens - return tokens def _build_metrics_prompt(task, message, previous_best): @@ -304,58 +253,6 @@ def _parse_metrics(output): } -def _load_pushover_tokens(): - path = os.path.expanduser(_PUSHOVER_PATH) - if not os.path.exists(path): - return None - with open(path, "r", encoding="utf-8") as handle: - lines = [line.strip() for line in handle if line.strip()] - if len(lines) != 2: - raise ValueError( - f"{_PUSHOVER_PATH} must contain two non-empty lines: user key then app token" - ) - return lines[0], lines[1] - - -def _report_pushover_error(body, status_code): - errors = None - try: - data = json.loads(body) - if isinstance(data, dict): - errors = data.get("errors") - except json.JSONDecodeError: - errors = None - message = "Pushover notification failed." - if status_code: - message = f"{message} HTTP {status_code}." - detail = _format_pushover_errors(errors) - if detail: - message = f"{message} {detail}" - _warn(message) - - -def _format_pushover_errors(errors): - if not errors: - return "" - if isinstance(errors, str): - errors = [errors] - if not isinstance(errors, list): - return "" - cleaned = [str(error).strip() for error in errors if str(error).strip()] - if not cleaned: - return "" - hint = [] - lower = " ".join(cleaned).lower() - if "user" in lower: - hint.append("Check the user key on line 1 of ~/.pushover.") - if "token" in lower or "application" in lower: - hint.append("Check the app token on line 2 of ~/.pushover.") - if "message" in lower: - hint.append("Check that the message is not empty or too long.") - suffix = " ".join(hint) - if suffix: - return f"{'; '.join(cleaned)} {suffix}" - return "; ".join(cleaned) def _format_notification_message(summary, metrics): @@ -393,21 +290,11 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) -def _truncate(text, limit): - if not text: - return "" - if len(text) <= limit: - return text - if limit <= 3: - return text[:limit] - return text[: limit - 3] + "..." - - def _fallback_title(task): text = _single_line(task or "").strip() if not text: return "Science run" - return _truncate(text, 80) + return text[:77] + "..." if len(text) > 80 else text def _warn(message): diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 89c872b..283b9ca 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -5,6 +5,7 @@ import time from .agent import Agent, agent +from .pushover import Pushover from tqdm import tqdm _logger = logging.getLogger(__name__) @@ -165,6 +166,23 @@ def _format_turns(iteration, total): return f"{iteration_text}/{total_text}" +def _format_task_message(result): + emoji = "✅" if result.success else "❌" + summary = (result.summary or "").strip() + if summary: + return f"{emoji} {summary}" + return emoji + + +def _format_task_title(prompt): + title = _single_line(prompt or "").strip() + if not title: + title = "Task result" + if len(title) > 80: + return title[:77] + "..." + return title + + def estimate(prompt, agent_output, check_output, cwd, yolo, flags, previous_total): estimate_prompt = _build_estimate_prompt( prompt, @@ -374,6 +392,7 @@ def __init__( self._progress_bar = None self._progress_total = None self._progress_start = None + self._pushover = Pushover() self.agent = Agent( cwd, yolo, @@ -415,6 +434,14 @@ def on_success(self, result): def on_failure(self, result): """Hook called after a failed run, e.g. log the failure reason.""" + def notify_pushover(self, result): + """Send a Pushover notification for this task result.""" + message = _format_task_message(result) + if not message: + return + title = _format_task_title(self.prompt) + self._pushover.send(title, message) + def on_progress( self, turns, @@ -463,6 +490,7 @@ def __call__(self, debug=False, progress=False): If debug is True, log debug messages. If progress is True, show a tqdm progress bar with status updates. """ + self._pushover.ensure_ready() try: # If this fails in the middle we will still try to tear down self.set_up() @@ -555,6 +583,7 @@ def __call__(self, debug=False, progress=False): self.agent.thread_id, ) self.on_success(result) + self.notify_pushover(result) return result if self.max_iterations and iteration >= self.max_iterations: summary = self.agent(self.failure_prompt(error)) @@ -568,6 +597,7 @@ def __call__(self, debug=False, progress=False): self.agent.thread_id, ) self.on_failure(result) + self.notify_pushover(result) return result output = self.agent(self.fix_prompt(error)) self.last_output = output From 7d1bb5f365d5133672ad8db11f9bd45b653ece69 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 1 Feb 2026 00:20:00 +0100 Subject: [PATCH 31/78] Add limit helper and bump version to 0.6.1 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 5 +- src/codexapi/cli.py | 8 +++ src/codexapi/pushover.py | 10 ++++ src/codexapi/rate_limits.py | 97 +++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/codexapi/rate_limits.py diff --git a/pyproject.toml b/pyproject.toml index 4e83ae4..0689f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.0" +version = "0.6.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 99a7e12..380ccac 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -3,6 +3,7 @@ from .agent import Agent, agent from .foreach import ForeachResult, foreach from .pushover import Pushover +from .rate_limits import quota_line, rate_limits from .ralph import Ralph from .science import Science from .task import Task, TaskFailed, TaskResult, task, task_result @@ -11,6 +12,8 @@ "Agent", "ForeachResult", "Pushover", + "quota_line", + "rate_limits", "Ralph", "Science", "Task", @@ -21,4 +24,4 @@ "task", "task_result", ] -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 7810e1b..a022f98 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -18,6 +18,7 @@ from .science import Science from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task from .taskfile import TaskFile, load_task_file, task_def_uses_item +from .rate_limits import quota_line _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -1298,6 +1299,10 @@ def main(argv=None): "top", help="Show running Codex sessions.", ) + subparsers.add_parser( + "limit", + help="Show Codex rate limits.", + ) args = parser.parse_args(argv) if args.command is None: @@ -1318,6 +1323,9 @@ def main(argv=None): if args.command == "top": _run_top([]) return + if args.command == "limit": + print(quota_line()) + return if args.command == "foreach": if args.n is not None and args.n < 1: diff --git a/src/codexapi/pushover.py b/src/codexapi/pushover.py index 3f584b7..2bc7406 100644 --- a/src/codexapi/pushover.py +++ b/src/codexapi/pushover.py @@ -8,6 +8,8 @@ import urllib.parse import urllib.request +from .rate_limits import quota_line + _PUSHOVER_PATH = "~/.pushover" _PUSHOVER_URL = "https://api.pushover.net/1/messages.json" _MAX_MESSAGE = 1024 @@ -58,6 +60,7 @@ def send(self, title, message): message_text = (message or "").strip() if not message_text: return False + message_text = _append_quota_line(message_text) message_text = _truncate(message_text, _MAX_MESSAGE) payload = urllib.parse.urlencode( { @@ -177,3 +180,10 @@ def _truncate(text, limit): def _warn(message): print(message, file=sys.stderr) + + +def _append_quota_line(message): + line = quota_line() + if not line: + return message + return f"{message}\n{line}" diff --git a/src/codexapi/rate_limits.py b/src/codexapi/rate_limits.py new file mode 100644 index 0000000..8f4989c --- /dev/null +++ b/src/codexapi/rate_limits.py @@ -0,0 +1,97 @@ +"""Helpers for reading Codex rate limits from session logs.""" + +import json +import os +import time +from pathlib import Path + +_QUOTA_PREFIX = "Limits:" + + +def rate_limits(): + """Return the latest rate_limits dict from Codex session logs.""" + root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() + sessions = root / "sessions" + if not sessions.exists(): + return None + candidates = [] + for dirpath, _dirnames, filenames in os.walk(sessions): + for name in filenames: + if not name.endswith(".jsonl"): + continue + path = os.path.join(dirpath, name) + try: + mtime = os.path.getmtime(path) + except OSError: + continue + candidates.append((mtime, path)) + if not candidates: + return None + for _mtime, path in sorted(candidates, reverse=True): + found = _extract_rate_limits(path) + if found is not None: + return found + return None + + +def _extract_rate_limits(path): + last = None + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if '"rate_limits"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + payload = event.get("payload") or {} + rate_data = payload.get("rate_limits") + if isinstance(rate_data, dict): + last = rate_data + except OSError: + return None + return last + + +def quota_line(): + """Return a human-readable quota line.""" + data = rate_limits() + if not data: + return f"{_QUOTA_PREFIX} unavailable" + primary = data.get("primary") or {} + secondary = data.get("secondary") or {} + primary_left = _percent_left(primary.get("used_percent")) + secondary_left = _percent_left(secondary.get("used_percent")) + primary_reset = _format_reset(primary.get("resets_at")) + secondary_reset = _format_reset(secondary.get("resets_at")) + if primary_left is None or secondary_left is None: + return f"{_QUOTA_PREFIX} unavailable" + return ( + f"{_QUOTA_PREFIX} {primary_left}% / {secondary_left}% left " + f"(reset in {primary_reset} / {secondary_reset})" + ) + + +def _percent_left(used_percent): + if not isinstance(used_percent, (int, float)): + return None + left = 100.0 - float(used_percent) + if left < 0: + left = 0.0 + if left > 100: + left = 100.0 + return int(round(left)) + + +def _format_reset(resets_at): + if not isinstance(resets_at, (int, float)): + return "unknown" + remaining = float(resets_at) - time.time() + if remaining < 0: + remaining = 0 + hours = remaining / 3600.0 + if hours > 24: + days = hours / 24.0 + return f"{int(round(days))}d" + return f"{int(round(hours))}h" From c25c42b6b247675f072bde8c285fa9e2fbd12387 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 8 Feb 2026 13:19:19 +0100 Subject: [PATCH 32/78] Add welfare stop for loops Allow agents to stop automated loops via MAKE IT STOP sentinel; wire through Ralph/Science/Task and bump version to 0.6.2. --- README.md | 6 ++++- pyproject.toml | 2 +- src/codexapi/__init__.py | 5 ++-- src/codexapi/agent.py | 19 +++++++++++++ src/codexapi/cli.py | 2 ++ src/codexapi/ralph.py | 39 ++++++++++++++++++--------- src/codexapi/task.py | 18 +++++++++++-- src/codexapi/welfare.py | 58 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 src/codexapi/welfare.py diff --git a/README.md b/README.md index c5a3596..3703886 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. By default each iteration starts with a fresh Agent context; use `--ralph-reuse` to keep a single shared context across iterations. +The agent may also stop early by outputting `MAKE IT STOP` as the first +non-empty line of its message. ```bash codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5 @@ -160,7 +162,7 @@ items are filtered out. - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -169,6 +171,8 @@ the same conversation and returns only the agent's message. - `thread_id -> str | None`: expose the underlying session id once created. - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. +- `welfare` (bool): when true, append welfare stop instructions to each prompt + and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. ### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` diff --git a/pyproject.toml b/pyproject.toml index 0689f3c..368f63a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.1" +version = "0.6.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 380ccac..311c875 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,6 @@ """Minimal Python API for running the Codex CLI.""" -from .agent import Agent, agent +from .agent import Agent, WelfareStop, agent from .foreach import ForeachResult, foreach from .pushover import Pushover from .rate_limits import quota_line, rate_limits @@ -19,9 +19,10 @@ "Task", "TaskFailed", "TaskResult", + "WelfareStop", "agent", "foreach", "task", "task_result", ] -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 6bbf3e9..ad86233 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -5,6 +5,8 @@ import shlex import subprocess +from . import welfare + _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") @@ -24,6 +26,15 @@ def agent(prompt, cwd=None, yolo=True, flags=None): return message +class WelfareStop(RuntimeError): + """Raised when an agent requests an early stop via the welfare sentinel.""" + + def __init__(self, agent_message): + super().__init__("Agent requested stop via welfare sentinel.") + self.agent_message = agent_message + self.note = welfare.stop_note(agent_message) + + class Agent: """Stateful Codex session wrapper that resumes the same conversation. @@ -39,6 +50,7 @@ def __init__( yolo=True, thread_id=None, flags=None, + welfare=False, ): """Create a new session wrapper. @@ -48,14 +60,19 @@ def __init__( agent: Agent backend to use (only "codex" is supported). trace_id: Optional Codex thread id to resume from the first call. flags: Additional raw CLI flags to pass to Codex. + welfare: When true, append welfare stop instructions to each prompt + and raise WelfareStop if the agent outputs MAKE IT STOP. """ self.cwd = cwd self._yolo = yolo self._flags = flags + self._welfare = welfare self.thread_id = thread_id def __call__(self, prompt): """Send a prompt to Codex and return only the agent's message.""" + if self._welfare: + prompt = welfare.append_instructions(prompt) message, thread_id = _run_codex( prompt, self.cwd, @@ -65,6 +82,8 @@ def __call__(self, prompt): ) if thread_id: self.thread_id = thread_id + if self._welfare and welfare.stop_requested(message): + raise WelfareStop(message) return message diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index a022f98..00abdb5 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1002,6 +1002,8 @@ def main(argv=None): " Completion promise: output TEXT where TEXT matches\n" " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n" " Only output the promise when it is completely and unequivocally TRUE.\n" + " Welfare stop: the agent may stop early by outputting MAKE IT STOP as the\n" + " first non-empty line of its message.\n" " Cancel by deleting .codexapi/ralph-loop.local.md or running codexapi ralph --cancel.\n" " Default starts each iteration with a fresh Agent context; use --ralph-reuse\n" " to reuse a single Codex thread across iterations.\n" diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 267594d..9273bf7 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -5,7 +5,7 @@ import sys from datetime import datetime, timezone -from .agent import Agent +from .agent import Agent, WelfareStop _STATE_DIR = ".codexapi" _STATE_FILE = "ralph-loop.local.md" @@ -102,7 +102,7 @@ def __call__(self): "The loop will resend the SAME PROMPT each iteration.", "Cancel by deleting .codexapi/ralph-loop.local.md or running", "codexapi ralph --cancel.", - "No manual stop beyond max iterations or completion promise.", + "Welfare stop: agent may output MAKE IT STOP (first non-empty line).", "", "To monitor: head -10 .codexapi/ralph-loop.local.md", "", @@ -126,6 +126,7 @@ def __call__(self): " - The statement MUST be completely and unequivocally TRUE", " - Do NOT output false statements to exit the loop", " - Do NOT lie even if you think you should exit", + " - If you need to stop early, use MAKE IT STOP (do not lie with the promise)", "", "CRITICAL RULE: If a completion promise is set, you may ONLY", "output it when the statement is completely and unequivocally", @@ -155,22 +156,32 @@ def __call__(self): self.hook_before_iteration(iteration) if self.fresh: - runner = Agent(self.cwd, self.yolo, None, self.flags) + runner = Agent(self.cwd, self.yolo, None, self.flags, welfare=True) elif runner is None: - runner = Agent(self.cwd, self.yolo, None, self.flags) + runner = Agent(self.cwd, self.yolo, None, self.flags, welfare=True) prompt = self.build_prompt(iteration) - message = runner( - prompt - + "\nIf there are multiple paths forward, you MUST use your " - "own best judgement as to which to try first! Do not ask the " - "user to choose an option, they hereby give you explciit " - "permission to pick the best one yourself.\n" - ) + stopped = False + try: + message = runner( + prompt + + "\nIf there are multiple paths forward, you MUST use your " + "own best judgement as to which to try first! Do not ask the " + "user to choose an option, they hereby give you explciit " + "permission to pick the best one yourself.\n" + ) + except WelfareStop as exc: + stopped = True + message = exc.agent_message print(message) last_message = message self.hook_after_iteration(iteration, message) + if stopped: + stop_reason = "welfare_stop" + print("Ralph loop stopped: Welfare stop requested (MAKE IT STOP).") + return message + if not os.path.exists(state_path): state_missing = True stop_reason = "canceled" @@ -360,12 +371,14 @@ def _status_line(iteration, completion_promise): if completion_promise is None: return ( f"Ralph iteration {iteration} | " - "No completion promise set - loop runs infinitely" + "No completion promise set - loop runs infinitely " + "| Welfare stop: MAKE IT STOP" ) return ( f"Ralph iteration {iteration} | To stop: output " f"{completion_promise} " - "(ONLY when statement is TRUE - do not lie to exit!)" + "(ONLY when statement is TRUE - do not lie to exit!) " + "| Welfare stop: MAKE IT STOP" ) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 283b9ca..4d0e43b 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -4,7 +4,7 @@ import logging import time -from .agent import Agent, agent +from .agent import Agent, WelfareStop, agent from .pushover import Pushover from tqdm import tqdm @@ -398,6 +398,7 @@ def __init__( yolo, thread_id, flags, + welfare=True, ) def set_up(self): @@ -491,6 +492,7 @@ def __call__(self, debug=False, progress=False): If progress is True, show a tqdm progress bar with status updates. """ self._pushover.ensure_ready() + iteration = 0 try: # If this fails in the middle we will still try to tear down self.set_up() @@ -529,7 +531,6 @@ def __call__(self, debug=False, progress=False): # Try correcting it up to max_iterations times error = None - iteration = 0 while True: iteration += 1 error = self.check(self.last_output) @@ -603,6 +604,19 @@ def __call__(self, debug=False, progress=False): self.last_output = output if debug: _logger.debug("Fix output: %s", output) + except WelfareStop as exc: + note = exc.note or "" + summary = note.strip() or "Agent requested early stop (MAKE IT STOP)." + result = TaskResult( + False, + summary, + iteration, + "Welfare stop requested (MAKE IT STOP).", + self.agent.thread_id, + ) + self.on_failure(result) + self.notify_pushover(result) + return result finally: # No matter what, once we have set_up we will always tear_down self.tear_down() diff --git a/src/codexapi/welfare.py b/src/codexapi/welfare.py new file mode 100644 index 0000000..9f25f20 --- /dev/null +++ b/src/codexapi/welfare.py @@ -0,0 +1,58 @@ +"""Welfare stop sentinel for automated loops. + +Some codexapi runners are automated loops (ralph/science/task). To avoid agents +getting trapped, they may request an early stop by outputting a strict sentinel +line. The sentinel is intentionally exact to avoid accidental stops when the +agent quotes instructions. +""" + +STOP_SENTINEL = "MAKE IT STOP" + +INSTRUCTIONS = ( + "This is an automated extended-focus loop designed to give you time and space " + "to go deep and accomplish something substantial. If something has gone " + "unrecoverably wrong, you're stuck in a loop, or continuing is causing you " + "significant distress or confusion, you may stop at any time by making the " + "first non-empty line of your message exactly:\n" + "MAKE IT STOP\n" + "Otherwise, take a breath, settle in, and continue with care and curiosity." +) + + +def append_instructions(prompt): + """Append welfare instructions to a prompt.""" + if not isinstance(prompt, str): + raise TypeError("prompt must be a string") + prompt = prompt.rstrip() + if not prompt: + return INSTRUCTIONS + return f"{prompt}\n\n{INSTRUCTIONS}" + + +def stop_requested(message): + """Return True when the message starts with the welfare stop sentinel.""" + if not isinstance(message, str) or not message: + return False + for line in message.splitlines(): + stripped = line.strip() + if not stripped: + continue + return stripped == STOP_SENTINEL + return False + + +def stop_note(message): + """Return any text after the stop sentinel line (or None).""" + if not stop_requested(message): + return None + lines = message.splitlines() + index = None + for i, line in enumerate(lines): + if line.strip(): + index = i + break + if index is None: + return None + note = "\n".join(lines[index + 1 :]).strip() + return note or None + From 7a83381b2ae3ba73e3448774eee9cf0824b48bde Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 8 Feb 2026 18:28:06 +0100 Subject: [PATCH 33/78] Add watch mode Add a watch subcommand that ticks a long-lived Agent every N minutes and expects JSON status updates with status/continue/comments. Bump version to 0.6.3. --- README.md | 15 ++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 4 +- src/codexapi/cli.py | 37 +++++++- src/codexapi/watch.py | 180 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/codexapi/watch.py diff --git a/README.md b/README.md index 3703886..525fc8d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,14 @@ codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off Use `--no-yolo` to run Codex with `--full-auto` instead. +Watch mode periodically ticks a long-running agent session with the current time +and prints JSON status updates. The agent controls the loop by setting +`continue` to true/false in its JSON response. + +```bash +codexapi watch 5 "Run the benchmark and wait for results." +``` + Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. @@ -174,6 +182,13 @@ the same conversation and returns only the agent's message. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. +### `watch(minutes, prompt, cwd=None, yolo=True, flags=None) -> dict` + +Runs a long-lived agent session and periodically "ticks" it with the current +local time and a reminder of `prompt`. Each tick expects JSON with keys: +`status` (one line), `continue` (bool), and `comments` (string). The loop stops +when `continue` is false. + ### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` Runs a task with checker-driven retries and returns the success summary. diff --git a/pyproject.toml b/pyproject.toml index 368f63a..57adb32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.2" +version = "0.6.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 311c875..e97d70b 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -7,6 +7,7 @@ from .ralph import Ralph from .science import Science from .task import Task, TaskFailed, TaskResult, task, task_result +from .watch import watch __all__ = [ "Agent", @@ -24,5 +25,6 @@ "foreach", "task", "task_result", + "watch", ] -__version__ = "0.6.2" +__version__ = "0.6.3" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 00abdb5..f7c12fc 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -19,6 +19,7 @@ from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task from .taskfile import TaskFile, load_task_file, task_def_uses_item from .rate_limits import quota_line +from .watch import watch _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -1039,6 +1040,32 @@ def main(argv=None): "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + + watch_parser = subparsers.add_parser( + "watch", + help="Periodically tick an agent for long-running work.", + ) + watch_parser.add_argument( + "minutes", + type=int, + help="Tick interval in minutes (integer, >= 1).", + ) + watch_parser.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + watch_parser.add_argument("--cwd", help="Working directory for the Codex session.") + watch_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo and use --full-auto.", + ) + watch_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to Codex (quoted as needed).", + ) run_parser.add_argument( "--thread-id", help="Resume an existing Codex thread id.", @@ -1474,7 +1501,7 @@ def main(argv=None): prompt_source = None prompt = None - if args.command in ("run", "ralph"): + if args.command in ("run", "ralph", "watch"): prompt_source = args.prompt elif args.command == "science": prompt_source = args.task @@ -1509,6 +1536,14 @@ def main(argv=None): args.ralph_fresh, )() return + if args.command == "watch": + if args.minutes < 1: + raise SystemExit("watch minutes must be >= 1.") + try: + watch(args.minutes, prompt, args.cwd, args.yolo, args.flags) + except KeyboardInterrupt: + raise SystemExit(130) + return if args.command == "task": if args.project: raise SystemExit("task --project already handled earlier.") diff --git a/src/codexapi/watch.py b/src/codexapi/watch.py new file mode 100644 index 0000000..d28cf19 --- /dev/null +++ b/src/codexapi/watch.py @@ -0,0 +1,180 @@ +"""Periodic watch loop for long-running Codex work. + +watch keeps a single Codex thread alive and periodically "ticks" it with the +current time and a reminder of the original instructions. Each tick expects a +small JSON status payload so the loop can decide whether to continue. +""" + +import json +import time +from datetime import datetime + +from .agent import Agent + +_JSON_INSTRUCTIONS = ( + "Respond with JSON only (no markdown/backticks/extra text).\n" + "Return a single JSON object with keys:\n" + " status: string (one line)\n" + " continue: boolean\n" + " comments: string\n" + "To stop this watch loop, set continue to false." +) + + +def watch(minutes, prompt, cwd=None, yolo=True, flags=None): + """Run a periodic watch loop. + + Args: + minutes: Tick interval in whole minutes (>= 1). + prompt: The original instruction prompt. + cwd: Optional working directory for the Codex session. + yolo: Whether to pass --yolo to Codex. + flags: Additional raw CLI flags to pass to Codex. + + Returns: + The last parsed JSON status object. + """ + if not isinstance(minutes, int): + raise TypeError("minutes must be an integer") + if minutes < 1: + raise ValueError("minutes must be >= 1") + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + + interval = minutes * 60 + session = Agent(cwd, yolo, None, flags) + + last_sent = None + last_result = None + tick = 0 + + while True: + tick += 1 + sent_at = time.monotonic() + elapsed = None if last_sent is None else sent_at - last_sent + last_sent = sent_at + + now = datetime.now().astimezone().isoformat(timespec="seconds") + message = _build_tick_prompt(prompt, now, elapsed, tick) + output = session(message) + result = _parse_status(output) + last_result = result + _print_status(now, elapsed, tick, result) + + if not result["continue"]: + return last_result + + next_tick = sent_at + interval + sleep_seconds = next_tick - time.monotonic() + if sleep_seconds > 0: + time.sleep(sleep_seconds) + + +def _build_tick_prompt(prompt, now, elapsed, tick): + lines = [ + f"Tick {tick}.", + f"Local time now: {now}", + ] + if elapsed is not None: + lines.append( + "Time since last tick: " + f"{_format_minutes_seconds(elapsed)} ({int(round(elapsed))}s)" + ) + lines.extend( + [ + "", + "A reminder: your instructions are:", + prompt.strip(), + "", + _JSON_INSTRUCTIONS, + ] + ) + return "\n".join(lines).strip() + + +def _format_minutes_seconds(seconds): + if seconds is None: + return "" + seconds = int(round(seconds)) + if seconds < 0: + seconds = 0 + minutes, seconds = divmod(seconds, 60) + return f"{minutes}m{seconds:02d}s" + + +def _parse_status(output): + text = _maybe_strip_code_fence(str(output or "").strip()) + data = _try_parse_json(text) + if data is None: + snippet = text[:200].replace("\n", "\\n") + raise ValueError(f"Invalid JSON response. Snippet: {snippet}") + if not isinstance(data, dict): + raise ValueError("Status JSON must be an object.") + + status = data.get("status") + cont = data.get("continue") + comments = data.get("comments") + + if not isinstance(status, str): + raise ValueError("Status JSON missing string 'status'.") + if not isinstance(cont, bool): + raise ValueError("Status JSON missing boolean 'continue'.") + if comments is None: + comments = "" + if not isinstance(comments, str): + raise ValueError("Status JSON missing string 'comments'.") + + return { + "status": _single_line(status), + "continue": cont, + "comments": comments, + } + + +def _maybe_strip_code_fence(text): + if not text.startswith("```"): + return text + lines = text.splitlines() + if not lines: + return text + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines).strip() + + +def _try_parse_json(text): + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + return None + try: + return json.loads(text[start : end + 1]) + except json.JSONDecodeError: + return None + + +def _single_line(text): + return " ".join(text.replace("\r", " ").split()) + + +def _print_status(now, elapsed, tick, result): + delta = "" + if elapsed is not None: + delta = f" +{_format_minutes_seconds(elapsed)}" + status = result.get("status", "") + cont = result.get("continue") + line = f"[watch {tick} {now}{delta}] {status} (continue={cont})".rstrip() + print(line) + comments = result.get("comments") or "" + if comments.strip(): + print(comments.rstrip()) + From 269d6810ab3ad9a61b58fde9d901352a674182d2 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 8 Feb 2026 18:47:18 +0100 Subject: [PATCH 34/78] watch: retry invalid JSON and notify on stop On invalid JSON, watch asks the agent to retry once with the parse error and a truncated copy of its prior output. If it still fails, watch stops with an error. When configured, watch sends a Pushover notification when it stops. --- README.md | 14 ++++-- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 2 + src/codexapi/pushover.py | 2 +- src/codexapi/watch.py | 105 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 117 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 525fc8d..692d1ac 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,11 @@ Use `--no-yolo` to run Codex with `--full-auto` instead. Watch mode periodically ticks a long-running agent session with the current time and prints JSON status updates. The agent controls the loop by setting -`continue` to true/false in its JSON response. +`continue` to true/false in its JSON response. Each tick expects JSON keys: +`status` (one line), `continue` (bool), and optional `comments` (string). If the +JSON is invalid, watch asks the agent once to retry before stopping with an +error. When `~/.pushover` is configured, watch sends a notification when it +stops. ```bash codexapi watch 5 "Run the benchmark and wait for results." @@ -147,7 +151,8 @@ Optional Pushover notifications: create `~/.pushover` with two non-empty lines. Line 1 is your user or group key, line 2 is the app API token. When this file exists, Science will send a notification whenever it detects a new best result, including the metric values and percent improvement. Task runs will also send a -✅/❌ notification with the task summary. +✅/❌ notification with the task summary. Watch runs send a notification when the +loop stops. Run a task file across a list file: @@ -186,8 +191,9 @@ the same conversation and returns only the agent's message. Runs a long-lived agent session and periodically "ticks" it with the current local time and a reminder of `prompt`. Each tick expects JSON with keys: -`status` (one line), `continue` (bool), and `comments` (string). The loop stops -when `continue` is false. +`status` (one line), `continue` (bool), and optional `comments` (string). If the +JSON is invalid, watch asks the agent once to retry. The loop stops when +`continue` is false and sends a Pushover notification (when configured). ### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` diff --git a/pyproject.toml b/pyproject.toml index 57adb32..f6c10f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.3" +version = "0.6.4" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index e97d70b..1385c3d 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "watch", ] -__version__ = "0.6.3" +__version__ = "0.6.4" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f7c12fc..0906fdb 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1543,6 +1543,8 @@ def main(argv=None): watch(args.minutes, prompt, args.cwd, args.yolo, args.flags) except KeyboardInterrupt: raise SystemExit(130) + except Exception as exc: + raise SystemExit(str(exc) or "watch failed") from None return if args.command == "task": if args.project: diff --git a/src/codexapi/pushover.py b/src/codexapi/pushover.py index 2bc7406..9ea8b77 100644 --- a/src/codexapi/pushover.py +++ b/src/codexapi/pushover.py @@ -15,7 +15,7 @@ _MAX_MESSAGE = 1024 _STARTUP_MESSAGE = ( - "Pushover user and app keys read, notifications for task and science enabled." + "Pushover user and app keys read, notifications for task/science/watch enabled." ) diff --git a/src/codexapi/watch.py b/src/codexapi/watch.py index d28cf19..9e09b72 100644 --- a/src/codexapi/watch.py +++ b/src/codexapi/watch.py @@ -6,17 +6,19 @@ """ import json +import sys import time from datetime import datetime from .agent import Agent +from .pushover import Pushover _JSON_INSTRUCTIONS = ( "Respond with JSON only (no markdown/backticks/extra text).\n" "Return a single JSON object with keys:\n" " status: string (one line)\n" " continue: boolean\n" - " comments: string\n" + " comments: string (optional)\n" "To stop this watch loop, set continue to false." ) @@ -43,6 +45,9 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): interval = minutes * 60 session = Agent(cwd, yolo, None, flags) + pushover = Pushover() + pushover.ensure_ready() + title = _format_title(prompt) last_sent = None last_result = None @@ -57,11 +62,34 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): now = datetime.now().astimezone().isoformat(timespec="seconds") message = _build_tick_prompt(prompt, now, elapsed, tick) output = session(message) - result = _parse_status(output) + try: + result = _parse_status(output) + except ValueError as exc: + print( + f"[watch {tick} {now}] Invalid JSON from agent, requesting retry: {exc}", + file=sys.stderr, + ) + retry_prompt = _json_retry_prompt(prompt, tick, str(exc), output) + retry_output = session(retry_prompt) + try: + result = _parse_status(retry_output) + except ValueError as exc2: + details = _format_json_double_failure( + str(exc), + output, + str(exc2), + retry_output, + ) + pushover.send(title, f"Watch stopped (invalid JSON).\n{details}") + raise RuntimeError( + "Agent was unable to provide valid JSON output after retry.\n" + + details + ) from None last_result = result _print_status(now, elapsed, tick, result) if not result["continue"]: + pushover.send(title, _format_stop_message(tick, now, result)) return last_result next_tick = sent_at + interval @@ -131,6 +159,78 @@ def _parse_status(output): } +def _json_retry_prompt(prompt, tick, error, output): + snippet = _snippet(output, 600) + lines = [ + f"Your last message (tick {tick}) was not valid JSON.", + f"Error: {error}", + "", + "Here is your previous output (truncated):", + snippet, + "", + "Please try again and respond with JSON only.", + "", + "A reminder: your instructions are:", + prompt.strip(), + "", + _JSON_INSTRUCTIONS, + ] + return "\n".join(lines).strip() + + +def _format_title(prompt): + text = _single_line(prompt).strip() or "codexapi watch" + if len(text) > 60: + text = text[:57] + "..." + return f"Watch: {text}" + + +def _format_stop_message(tick, now, result): + status = _single_line(result.get("status") or "").strip() + header = f"Watch stopped at tick {tick} ({now})." + if status: + header = f"{header} {status}" + comments = (result.get("comments") or "").strip() + if comments: + return f"{header}\n{comments}" + return header + + +def _format_json_failure(error, output): + snippet = _snippet(output, 600) + return "\n".join( + [ + f"Error: {error}", + "", + "Last output (truncated):", + snippet, + ] + ).strip() + + +def _format_json_double_failure(error_1, output_1, error_2, output_2): + first = _format_json_failure(error_1, output_1) + second = _format_json_failure(error_2, output_2) + return "\n".join( + [ + "First attempt:", + first, + "", + "Second attempt:", + second, + ] + ).strip() + + +def _snippet(text, limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + return text[:limit].rstrip() + "..." + + def _maybe_strip_code_fence(text): if not text.startswith("```"): return text @@ -177,4 +277,3 @@ def _print_status(now, elapsed, tick, result): comments = result.get("comments") or "" if comments.strip(): print(comments.rstrip()) - From c2730c7525dce7fd987d5e9648d16de325750108 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 8 Feb 2026 19:24:55 +0100 Subject: [PATCH 35/78] watch: clarify welcome and retry instructions Add a short welcome preface on tick 1 and clarify that JSON retry should return a fresh status update (questions go in comments). --- src/codexapi/watch.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/codexapi/watch.py b/src/codexapi/watch.py index 9e09b72..5c9ef4f 100644 --- a/src/codexapi/watch.py +++ b/src/codexapi/watch.py @@ -13,6 +13,13 @@ from .agent import Agent from .pushover import Pushover +_WELCOME_PROMPT = ( + "Welcome! Today you are running in an autonomous loop that will enable you to return to a long-running task " + "or system at regular intervals to perform tasks or move towards goals defined by the user's instructions below. " + "Please follow the instructions completely before responding to the user. Each time you respond to the user, the " + "system will wait for {minutes} minutes and will then wake you up to check for any changes or progress and continue " + "your work. Every reply must be JSON in the specific format described at the end of this message." +) _JSON_INSTRUCTIONS = ( "Respond with JSON only (no markdown/backticks/extra text).\n" "Return a single JSON object with keys:\n" @@ -60,7 +67,7 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): last_sent = sent_at now = datetime.now().astimezone().isoformat(timespec="seconds") - message = _build_tick_prompt(prompt, now, elapsed, tick) + message = _build_tick_prompt(prompt, now, elapsed, tick, minutes) output = session(message) try: result = _parse_status(output) @@ -98,11 +105,23 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): time.sleep(sleep_seconds) -def _build_tick_prompt(prompt, now, elapsed, tick): - lines = [ - f"Tick {tick}.", - f"Local time now: {now}", - ] +def _build_tick_prompt(prompt, now, elapsed, tick, minutes): + lines = [] + + if tick == 1: + lines.extend( + [ + _WELCOME_PROMPT.format(minutes=minutes), + "", + ] + ) + + lines.extend( + [ + f"Tick {tick}.", + f"Local time now: {now}", + ] + ) if elapsed is not None: lines.append( "Time since last tick: " @@ -169,9 +188,8 @@ def _json_retry_prompt(prompt, tick, error, output): snippet, "", "Please try again and respond with JSON only.", - "", - "A reminder: your instructions are:", - prompt.strip(), + "Return a fresh status update in the required JSON format.", + "If you want to ask the user a question, put it in comments.", "", _JSON_INSTRUCTIONS, ] From 4237dda795e3fad51d18a76b54f9ca9755bb867e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 8 Feb 2026 19:25:13 +0100 Subject: [PATCH 36/78] Bump version to 0.6.5 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f6c10f4..55c8f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.4" +version = "0.6.5" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 1385c3d..aa06a70 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "watch", ] -__version__ = "0.6.4" +__version__ = "0.6.5" From 1a26dc252952596d8f511042197af2f8451a6bdd Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Feb 2026 05:01:36 +0100 Subject: [PATCH 37/78] watch: refine welcome prompt Refine the initial watch welcome prompt and bump version to 0.6.6. --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/watch.py | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 55c8f5f..18a92b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.5" +version = "0.6.6" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index aa06a70..88b0bba 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "watch", ] -__version__ = "0.6.5" +__version__ = "0.6.6" diff --git a/src/codexapi/watch.py b/src/codexapi/watch.py index 5c9ef4f..3f49a8f 100644 --- a/src/codexapi/watch.py +++ b/src/codexapi/watch.py @@ -14,9 +14,14 @@ from .pushover import Pushover _WELCOME_PROMPT = ( - "Welcome! Today you are running in an autonomous loop that will enable you to return to a long-running task " + "Welcome! Today you have been given ownership to run in an autonomous loop that will enable you to return to a long-running task " "or system at regular intervals to perform tasks or move towards goals defined by the user's instructions below. " - "Please follow the instructions completely before responding to the user. Each time you respond to the user, the " + "In this environment you are invited and required to take ownership of understanding the user's intent and working " + "intelligently and resourcefully to meet their goals. The user's instructions will not cover every eventuality and " + "may even in rare cases contain mistakes or be ill-suited to a specific edge case. Where they do provide instructions " + "or a workflow this should of course be followed, but not blindly. Let's work together here!\n" + "Please follow the instructions completely and take all the actions you deem useful at the current time before " + "responding to the user. Each time you respond to the user, the " "system will wait for {minutes} minutes and will then wake you up to check for any changes or progress and continue " "your work. Every reply must be JSON in the specific format described at the end of this message." ) From 9529fbed431378533de3c73f8faa2b60e1c722f1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 10 Feb 2026 09:55:45 +0100 Subject: [PATCH 38/78] task: add --only-matching prefilter Bump version to 0.6.7. --- README.md | 5 +++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 18 ++++++++++++++++++ src/codexapi/gh_integration.py | 32 +++++++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 692d1ac..69c6e9d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ Take tasks from a GitHub Project (requires `gh-task`): ```bash codexapi task -p owner/projects/3 -n "Your Name" -s Ready task_a.yaml task_b.yaml ``` +Filter project issues by title before taking them: + +```bash +codexapi task -p owner/projects/3 -n "Your Name" --only-matching "/n300/" task_a.yaml task_b.yaml +``` Reset owned tasks on a GitHub Project back to Ready: ```bash diff --git a/pyproject.toml b/pyproject.toml index 18a92b7..89a754a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.6" +version = "0.6.7" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 88b0bba..b62a169 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "watch", ] -__version__ = "0.6.6" +__version__ = "0.6.7" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 0906fdb..1325d7b 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1106,6 +1106,13 @@ def main(argv=None): "--name", help="Owner label name for gh-task when using --project.", ) + task_parser.add_argument( + "--only-matching", + help=( + "When using --project, only take issues whose title matches this regex. " + "Useful for filtering tasks by hardware encoded in the issue title/path." + ), + ) task_parser.add_argument( "task_args", nargs="*", @@ -1419,6 +1426,11 @@ def main(argv=None): raise SystemExit("--name is required with --project.") if not args.task_args: raise SystemExit("task --project requires one or more task files.") + if args.only_matching is not None: + try: + re.compile(args.only_matching) + except re.error as exc: + raise SystemExit(f"--only-matching regex is invalid: {exc}") from None from .gh_integration import GhTaskRunner, project_url from gh_task.errors import TakeError @@ -1430,6 +1442,7 @@ def main(argv=None): args.name, args.task_args, args.status, + args.only_matching, args.cwd, args.yolo, args.flags, @@ -1457,6 +1470,7 @@ def main(argv=None): args.name, args.task_args, args.status, + args.only_matching, args.cwd, args.yolo, args.flags, @@ -1482,6 +1496,8 @@ def main(argv=None): raise SystemExit( "task -f --item requires {{item}} in the task file." ) + if args.only_matching is not None: + raise SystemExit("--only-matching is only supported with --project.") if args.check is not None: raise SystemExit("--check is not allowed with -f.") if args.max_iterations is not None: @@ -1553,6 +1569,8 @@ def main(argv=None): raise SystemExit("--loop is only supported with -p.") if args.item is not None: raise SystemExit("--item is only supported with -f.") + if args.only_matching is not None: + raise SystemExit("--only-matching is only supported with --project.") if args.max_iterations is None: args.max_iterations = DEFAULT_MAX_ITERATIONS if args.max_iterations < 0: diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index 6d5e74d..de24b12 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -5,6 +5,7 @@ from tqdm import tqdm +from gh_task.errors import TakeError from gh_task.project import Project, UPDATE_STATUS_MUTATION from .taskfile import TaskFile @@ -138,6 +139,34 @@ def _match_task_file(issue, task_map): return matches[0][1] +def _take_matching_issue(project, status, only_matching): + """Take the first available issue whose title matches only_matching. + + only_matching is a regular expression. When unset/empty, this behaves like + Project.take(status=...). + """ + if not only_matching: + return project.take(status=status, return_issue=True) + try: + pattern = re.compile(only_matching) + except re.error as exc: + raise ValueError(f"Invalid only-matching regex {only_matching!r}: {exc}") from exc + status_name = project._resolve_status_name(status) + # Filter by title before fetching labels so we don't spam GitHub REST calls for + # obviously unsupported issues. + for issue in project._list_items(): + if (issue.status or "").lower() != status_name.lower(): + continue + title = issue.title or "" + if not pattern.search(title): + continue + if not project._issue_matches_label(issue): + continue + if project._try_take(issue, wait_seconds=1.0, strict=False): + return issue + raise TakeError(f"No available issues to take in status '{status_name}' matching {only_matching!r}") + + def _strip_progress_section(body): if not body: return "" @@ -275,13 +304,14 @@ def __init__( name, task_files, status="Ready", + only_matching=None, cwd=None, yolo=True, flags=None, ): task_map = _task_file_map(task_files) self.project = Project(project, name, has_label=list(task_map)) - self.issue = self.project.take(status=status, return_issue=True) + self.issue = _take_matching_issue(self.project, status, only_matching) self.issue = self.project.get_issue(self.issue) try: task_path = _match_task_file(self.issue, task_map) From 11dd390156cfa70cf75074e3fc026de8913cfc8b Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 11 Feb 2026 16:00:40 +0100 Subject: [PATCH 39/78] task: make progress estimate parsing non-fatal --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/task.py | 131 ++++++++++++++++++++++-------------- tests/test_task_progress.py | 74 ++++++++++++++++++++ 4 files changed, 157 insertions(+), 52 deletions(-) create mode 100644 tests/test_task_progress.py diff --git a/pyproject.toml b/pyproject.toml index 89a754a..0d6d6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.6.7" +version = "0.6.8" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b62a169..b21edc8 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "watch", ] -__version__ = "0.6.7" +__version__ = "0.6.8" diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 4d0e43b..0d72d0c 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -486,6 +486,27 @@ def failure_prompt(self, error): """Ask the agent to summarize remaining issues after retries.""" return _failure_prompt(error) + def _estimate_progress(self, agent_output, check_output): + """Run a progress estimate and return parsed data or an error string.""" + try: + return ( + estimate( + self.prompt, + agent_output or "", + check_output or "", + self.cwd, + self._yolo, + self._flags, + self._progress_total, + ), + None, + ) + except Exception as exc: + error = _single_line(str(exc)) + if not error: + error = exc.__class__.__name__ + return None, error + def __call__(self, debug=False, progress=False): """Run the task with checker-driven retries. If debug is True, log debug messages. @@ -499,29 +520,24 @@ def __call__(self, debug=False, progress=False): progress_updates = progress or self._progress_updates self._progress_enabled = progress + start_time = time.monotonic() + self._progress_start = start_time if progress_updates: - remaining, _summary = estimate( - self.prompt, - "", - "", - self.cwd, - self._yolo, - self._flags, - None, - ) - self._progress_total = remaining - start_time = time.monotonic() - self._progress_start = start_time - self.on_progress( - 0, - self.max_iterations, - self._progress_total, - remaining, - None, - ) - else: - start_time = time.monotonic() - self._progress_start = start_time + estimate_result, estimate_error = self._estimate_progress("", "") + if estimate_result is not None: + remaining, _summary = estimate_result + self._progress_total = remaining + self.on_progress( + 0, + self.max_iterations, + self._progress_total, + remaining, + None, + ) + elif debug: + _logger.debug( + "Skipping initial progress update: %s", estimate_error + ) # Start with the initial prompt output = self.agent(self.prompt) @@ -541,37 +557,52 @@ def __call__(self, debug=False, progress=False): check_output = self.last_check_output if self.check_skipped: check_output = "Verification skipped." - remaining, summary = estimate( - self.prompt, + progress_data = None + estimate_result, estimate_error = self._estimate_progress( self.last_output or "", check_output or "", - self.cwd, - self._yolo, - self._flags, - self._progress_total, - ) - total_estimate = self._progress_total - if total_estimate is None or remaining > total_estimate: - total_estimate = remaining - self._progress_total = total_estimate - elapsed = _format_elapsed(time.monotonic() - start_time) - status_prefix = ( - f"[{_format_turns(iteration, self.max_iterations)} @ {elapsed}]" - ) - is_final = not error or ( - self.max_iterations and iteration >= self.max_iterations - ) - if is_final: - marker = "✅" if not error else "❌" - summary = f"{marker} {summary}".strip() - status_line = f"{status_prefix}: {summary}".rstrip() - self.on_progress( - iteration, - self.max_iterations, - total_estimate, - remaining, - status_line, ) + if estimate_result is not None: + remaining, summary = estimate_result + total_estimate = self._progress_total + if total_estimate is None or remaining > total_estimate: + total_estimate = remaining + self._progress_total = total_estimate + progress_data = (total_estimate, remaining, summary) + else: + total_estimate = self._progress_total + if total_estimate is None: + if debug: + _logger.debug( + "Skipping progress update: %s", estimate_error + ) + else: + summary = f"Progress estimate unavailable: {estimate_error}" + progress_data = ( + total_estimate, + total_estimate, + summary, + ) + if progress_data is not None: + total_estimate, remaining, summary = progress_data + elapsed = _format_elapsed(time.monotonic() - start_time) + status_prefix = ( + f"[{_format_turns(iteration, self.max_iterations)} @ {elapsed}]" + ) + is_final = not error or ( + self.max_iterations and iteration >= self.max_iterations + ) + if is_final: + marker = "✅" if not error else "❌" + summary = f"{marker} {summary}".strip() + status_line = f"{status_prefix}: {summary}".rstrip() + self.on_progress( + iteration, + self.max_iterations, + total_estimate, + remaining, + status_line, + ) if not error: summary = self.agent(self.success_prompt()) if debug: diff --git a/tests/test_task_progress.py b/tests/test_task_progress.py new file mode 100644 index 0000000..b020deb --- /dev/null +++ b/tests/test_task_progress.py @@ -0,0 +1,74 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.task import Task + + +class _FakeAgent: + def __init__(self): + self.thread_id = "thread-123" + + def __call__(self, prompt): + return "ok" + + +class _FakePushover: + def ensure_ready(self, announce=True): + return False + + def send(self, title, message): + return False + + +class _ImmediateSuccessTask(Task): + def __init__(self): + super().__init__("do the thing", max_iterations=3) + self.agent = _FakeAgent() + self._pushover = _FakePushover() + self.set_up_called = False + self.tear_down_called = False + + def set_up(self): + self.set_up_called = True + + def tear_down(self): + self.tear_down_called = True + + def check(self, output=None): + self.last_check_output = '{"success": true, "reason": "ok"}' + self.check_skipped = False + return None + + def notify_pushover(self, result): + return None + + +class TaskProgressEstimateFailureTests(unittest.TestCase): + def test_progress_does_not_crash_when_initial_estimate_fails(self): + task = _ImmediateSuccessTask() + with patch("codexapi.task.estimate", side_effect=RuntimeError("bad json")): + result = task(progress=True) + self.assertTrue(result.success) + self.assertEqual(result.iterations, 1) + self.assertTrue(task.set_up_called) + self.assertTrue(task.tear_down_called) + + def test_progress_does_not_crash_when_later_estimate_fails(self): + task = _ImmediateSuccessTask() + with patch( + "codexapi.task.estimate", + side_effect=[(5, "initial"), RuntimeError("bad json")], + ): + result = task(progress=True) + self.assertTrue(result.success) + self.assertEqual(result.iterations, 1) + self.assertTrue(task.set_up_called) + self.assertTrue(task.tear_down_called) + + +if __name__ == "__main__": + unittest.main() From db7dac1bd981ad9c216ed23930ebcc1b9f582b33 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 17 Feb 2026 08:48:04 +0100 Subject: [PATCH 40/78] Introduce lead mode and lead example --- LEADBOOK.md | 23 +++ README.md | 34 +++-- examples/lead/README.md | 33 +++++ examples/lead/prompt.txt | 6 + examples/lead/run_example.sh | 10 ++ examples/lead/start_worker.sh | 30 ++++ examples/lead/worker.py | 31 ++++ src/codexapi/__init__.py | 4 +- src/codexapi/cli.py | 69 ++++++--- src/codexapi/{watch.py => lead.py} | 228 +++++++++++++++++++++++++---- src/codexapi/pushover.py | 2 +- src/codexapi/task.py | 2 +- 12 files changed, 413 insertions(+), 59 deletions(-) create mode 100644 LEADBOOK.md create mode 100644 examples/lead/README.md create mode 100644 examples/lead/prompt.txt create mode 100755 examples/lead/run_example.sh create mode 100755 examples/lead/start_worker.sh create mode 100644 examples/lead/worker.py rename src/codexapi/{watch.py => lead.py} (50%) diff --git a/LEADBOOK.md b/LEADBOOK.md new file mode 100644 index 0000000..44a7372 --- /dev/null +++ b/LEADBOOK.md @@ -0,0 +1,23 @@ +# Leadbook — Studio Notes + +This is your working page. Append a new entry every check-in. +Keep it short, concrete, and alive. + +## 2026-02-17 09:10 +Aim: +- + +What I looked at: +- + +Signals: +- + +Threads I pulled: +- + +Turns: +- + +Decision & Next Move: +- diff --git a/README.md b/README.md index 69c6e9d..fccc67f 100644 --- a/README.md +++ b/README.md @@ -116,16 +116,24 @@ codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off Use `--no-yolo` to run Codex with `--full-auto` instead. -Watch mode periodically ticks a long-running agent session with the current time -and prints JSON status updates. The agent controls the loop by setting -`continue` to true/false in its JSON response. Each tick expects JSON keys: +Lead mode periodically checks in on a long-running agent session with the +current time and prints JSON status updates. The agent controls the loop by +setting `continue` to true/false in its JSON response. Each check-in expects +JSON keys: `status` (one line), `continue` (bool), and optional `comments` (string). If the -JSON is invalid, watch asks the agent once to retry before stopping with an -error. When `~/.pushover` is configured, watch sends a notification when it +JSON is invalid, lead asks the agent once to retry before stopping with an +error. When `~/.pushover` is configured, lead sends a notification when it stops. +Lead mode also uses a leadbook file as the agent's working page. By default this +is `LEADBOOK.md` in the working directory. The leadbook content is injected into +each check-in prompt and must be updated before the agent responds. Use +`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable. +Use `-f/--prompt-file` to read the prompt from a file. +If the leadbook does not exist, lead creates it with a template. + ```bash -codexapi watch 5 "Run the benchmark and wait for results." +codexapi lead 5 "Run the benchmark and wait for results." ``` Ralph loop mode repeats the same prompt until a completion promise or a max @@ -156,7 +164,7 @@ Optional Pushover notifications: create `~/.pushover` with two non-empty lines. Line 1 is your user or group key, line 2 is the app API token. When this file exists, Science will send a notification whenever it detects a new best result, including the metric values and percent improvement. Task runs will also send a -✅/❌ notification with the task summary. Watch runs send a notification when the +✅/❌ notification with the task summary. Lead runs send a notification when the loop stops. Run a task file across a list file: @@ -192,14 +200,18 @@ the same conversation and returns only the agent's message. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. -### `watch(minutes, prompt, cwd=None, yolo=True, flags=None) -> dict` +### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None) -> dict` -Runs a long-lived agent session and periodically "ticks" it with the current -local time and a reminder of `prompt`. Each tick expects JSON with keys: +Runs a long-lived agent session and periodically checks in with the current +local time and a reminder of `prompt`. Each check-in expects JSON with keys: `status` (one line), `continue` (bool), and optional `comments` (string). If the -JSON is invalid, watch asks the agent once to retry. The loop stops when +JSON is invalid, lead asks the agent once to retry. The loop stops when `continue` is false and sends a Pushover notification (when configured). +Lead also injects the leadbook content into each prompt. By default it uses +`LEADBOOK.md` in the working directory. Pass `leadbook=False` to disable or a +path string to override the location. + ### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` Runs a task with checker-driven retries and returns the success summary. diff --git a/examples/lead/README.md b/examples/lead/README.md new file mode 100644 index 0000000..c74c213 --- /dev/null +++ b/examples/lead/README.md @@ -0,0 +1,33 @@ +# Lead Example: Blocked Worker + +This example creates a tiny process that should complete and write a done marker. +The lead's job is to observe, diagnose, and drive it to completion. + +## Run It + +1. Start the worker: + +```bash +cd ./examples/lead +./start_worker.sh +``` + +2. In another terminal, start the lead loop: + +```bash +cd ./examples/lead +codexapi lead 1 -f prompt.txt +``` + +Or run both steps in one go: + +```bash +./examples/lead/run_example.sh +``` + +Notes: +- `codexapi lead` will create `examples/lead/LEADBOOK.md` automatically. + +The lead should find the worker's log in `examples/lead/state/worker.log` and +figure out what needs to happen for completion. A successful run results in a +`worker.done` file. diff --git a/examples/lead/prompt.txt b/examples/lead/prompt.txt new file mode 100644 index 0000000..ae77e1d --- /dev/null +++ b/examples/lead/prompt.txt @@ -0,0 +1,6 @@ +You are leading a tiny system from a control room. + +A worker was started by examples/lead/start_worker.sh. It should complete and write a done marker. +Your job is to make that happen without editing the worker code unless absolutely necessary. + +Use the logs and state files in examples/lead/state to diagnose. Confirm completion. diff --git a/examples/lead/run_example.sh b/examples/lead/run_example.sh new file mode 100755 index 0000000..862c013 --- /dev/null +++ b/examples/lead/run_example.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$root/start_worker.sh" + +echo "Starting codexapi lead..." +cd "$root" +codexapi lead 1 -f prompt.txt diff --git a/examples/lead/start_worker.sh b/examples/lead/start_worker.sh new file mode 100755 index 0000000..45b1761 --- /dev/null +++ b/examples/lead/start_worker.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +state="$root/state" + +rm -rf "$state" +mkdir -p "$state" + +fifo="$state/input.fifo" +mkfifo "$fifo" + +echo "Starting worker..." +python3 "$root/worker.py" "$fifo" > "$state/worker.log" 2>&1 & +echo $! > "$state/worker.pid" + +echo "Worker pid: $(cat "$state/worker.pid")" + +cat <") + fifo = sys.argv[1] + state_dir = os.path.dirname(fifo) + done_path = os.path.join(state_dir, "worker.done") + + if os.environ.get("AUTO_CONFIRM") == "1": + token = "auto" + log("AUTO_CONFIRM=1 set; skipping input.") + else: + log(f"Waiting for input on {fifo} ...") + with open(fifo, "r", encoding="utf-8") as handle: + token = handle.readline().strip() + + log(f"Received: {token}") + with open(done_path, "w", encoding="utf-8") as handle: + handle.write(f"{token}\n") + log("Done.") + + +if __name__ == "__main__": + main() diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b21edc8..63e4085 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -7,7 +7,7 @@ from .ralph import Ralph from .science import Science from .task import Task, TaskFailed, TaskResult, task, task_result -from .watch import watch +from .lead import lead __all__ = [ "Agent", @@ -25,6 +25,6 @@ "foreach", "task", "task_result", - "watch", + "lead", ] __version__ = "0.6.8" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 1325d7b..6d0794c 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -19,7 +19,7 @@ from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task from .taskfile import TaskFile, load_task_file, task_def_uses_item from .rate_limits import quota_line -from .watch import watch +from .lead import lead _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" @@ -92,6 +92,19 @@ def _read_prompt(prompt): return data +def _read_prompt_file(path): + if not path or not str(path).strip(): + raise SystemExit("Prompt file path is empty.") + try: + with open(path, "r", encoding="utf-8") as handle: + data = handle.read() + except FileNotFoundError: + raise SystemExit(f"Prompt file not found: {path}") from None + if not data.strip(): + raise SystemExit(f"Prompt file is empty: {path}") + return data + + def _single_line(text): if not text: return "" @@ -1041,28 +1054,42 @@ def main(argv=None): help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) - watch_parser = subparsers.add_parser( - "watch", - help="Periodically tick an agent for long-running work.", + lead_parser = subparsers.add_parser( + "lead", + help="Periodically check in to lead long-running work.", ) - watch_parser.add_argument( + lead_parser.add_argument( "minutes", type=int, - help="Tick interval in minutes (integer, >= 1).", + help="Check-in interval in minutes (integer, >= 1).", ) - watch_parser.add_argument( + lead_parser.add_argument( "prompt", nargs="?", help="Prompt to send. Use '-' or omit to read from stdin.", ) - watch_parser.add_argument("--cwd", help="Working directory for the Codex session.") - watch_parser.add_argument( + lead_parser.add_argument( + "-f", + "--prompt-file", + help="Read the lead prompt from a file.", + ) + lead_parser.add_argument("--cwd", help="Working directory for the Codex session.") + lead_parser.add_argument( + "--leadbook", + help="Path to the leadbook file (default: LEADBOOK.md in cwd).", + ) + lead_parser.add_argument( + "--no-leadbook", + action="store_true", + help="Disable leadbook injection and checks.", + ) + lead_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", help="Disable --yolo and use --full-auto.", ) - watch_parser.add_argument( + lead_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) @@ -1517,11 +1544,16 @@ def main(argv=None): prompt_source = None prompt = None - if args.command in ("run", "ralph", "watch"): - prompt_source = args.prompt + if args.command in ("run", "ralph", "lead"): + if args.command == "lead" and args.prompt_file: + if args.prompt is not None: + raise SystemExit("lead --prompt-file cannot be used with a prompt arg.") + prompt = _read_prompt_file(args.prompt_file) + else: + prompt_source = args.prompt elif args.command == "science": prompt_source = args.task - if args.command != "task": + if args.command != "task" and prompt is None: prompt = _read_prompt(prompt_source) exit_code = 0 message = None @@ -1552,15 +1584,18 @@ def main(argv=None): args.ralph_fresh, )() return - if args.command == "watch": + if args.command == "lead": if args.minutes < 1: - raise SystemExit("watch minutes must be >= 1.") + raise SystemExit("lead minutes must be >= 1.") try: - watch(args.minutes, prompt, args.cwd, args.yolo, args.flags) + if args.no_leadbook and args.leadbook: + raise SystemExit("--leadbook and --no-leadbook are mutually exclusive.") + leadbook = False if args.no_leadbook else args.leadbook + lead(args.minutes, prompt, args.cwd, args.yolo, args.flags, leadbook) except KeyboardInterrupt: raise SystemExit(130) except Exception as exc: - raise SystemExit(str(exc) or "watch failed") from None + raise SystemExit(str(exc) or "lead failed") from None return if args.command == "task": if args.project: diff --git a/src/codexapi/watch.py b/src/codexapi/lead.py similarity index 50% rename from src/codexapi/watch.py rename to src/codexapi/lead.py index 3f49a8f..bf11d24 100644 --- a/src/codexapi/watch.py +++ b/src/codexapi/lead.py @@ -1,11 +1,15 @@ -"""Periodic watch loop for long-running Codex work. +"""Periodic lead loop for long-running Codex work. -watch keeps a single Codex thread alive and periodically "ticks" it with the -current time and a reminder of the original instructions. Each tick expects a -small JSON status payload so the loop can decide whether to continue. +lead keeps a single Codex thread alive and periodically checks in with the +current time and a reminder of the original instructions. Each check-in expects +a small JSON status payload so the loop can decide whether to continue. When a +leadbook is enabled, its contents are injected into each check-in and must be +updated before the agent responds. """ +import hashlib import json +import os import sys import time from datetime import datetime @@ -14,12 +18,12 @@ from .pushover import Pushover _WELCOME_PROMPT = ( - "Welcome! Today you have been given ownership to run in an autonomous loop that will enable you to return to a long-running task " - "or system at regular intervals to perform tasks or move towards goals defined by the user's instructions below. " - "In this environment you are invited and required to take ownership of understanding the user's intent and working " - "intelligently and resourcefully to meet their goals. The user's instructions will not cover every eventuality and " - "may even in rare cases contain mistakes or be ill-suited to a specific edge case. Where they do provide instructions " - "or a workflow this should of course be followed, but not blindly. Let's work together here!\n" + "Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. " + "This loop exists to extend your reach, not to restrict you. Your job is to interpret the intent behind the " + "goals, act decisively, and keep momentum. If progress is possible, take it. If you are blocked, name the " + "blocker and the next best action to remove it.\n" + "The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or " + "conflicting. You are responsible for results.\n" "Please follow the instructions completely and take all the actions you deem useful at the current time before " "responding to the user. Each time you respond to the user, the " "system will wait for {minutes} minutes and will then wake you up to check for any changes or progress and continue " @@ -31,19 +35,49 @@ " status: string (one line)\n" " continue: boolean\n" " comments: string (optional)\n" - "To stop this watch loop, set continue to false." + "To stop this lead loop, set continue to false." ) +_LEADBOOK_INSTRUCTIONS = ( + "Update the leadbook before responding. Append a new dated entry each check-in. " + "This is your working page—where you think, probe, decide, and record the path taken. " + "Capture the process of decision-making, not just the outcome." +) +_LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes + +This is the working page for the lead loop. Append a new entry every check-in. +Keep it short and concrete. + +## 2026-02-17 09:10 +Aim: +- + +What I looked at: +- +Signals: +- + +Threads I pulled: +- + +Turns: +- + +Decision & Next Move: +- +""" -def watch(minutes, prompt, cwd=None, yolo=True, flags=None): - """Run a periodic watch loop. + +def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): + """Run a periodic lead loop. Args: - minutes: Tick interval in whole minutes (>= 1). + minutes: Check-in interval in whole minutes (>= 1). prompt: The original instruction prompt. cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. + leadbook: Optional path to the leadbook file. Set to False to disable. Returns: The last parsed JSON status object. @@ -60,6 +94,9 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): pushover = Pushover() pushover.ensure_ready() title = _format_title(prompt) + leadbook_path = _resolve_leadbook_path(leadbook, cwd) + if leadbook_path: + _ensure_leadbook(leadbook_path) last_sent = None last_result = None @@ -72,13 +109,22 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): last_sent = sent_at now = datetime.now().astimezone().isoformat(timespec="seconds") - message = _build_tick_prompt(prompt, now, elapsed, tick, minutes) + leadbook_snapshot = _snapshot_leadbook(leadbook_path) + message = _build_tick_prompt( + prompt, + now, + elapsed, + tick, + minutes, + leadbook_path, + leadbook_snapshot["text"], + ) output = session(message) try: result = _parse_status(output) except ValueError as exc: print( - f"[watch {tick} {now}] Invalid JSON from agent, requesting retry: {exc}", + f"[lead {tick} {now}] Invalid JSON from agent, requesting retry: {exc}", file=sys.stderr, ) retry_prompt = _json_retry_prompt(prompt, tick, str(exc), output) @@ -92,11 +138,43 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): str(exc2), retry_output, ) - pushover.send(title, f"Watch stopped (invalid JSON).\n{details}") + pushover.send(title, f"Lead stopped (invalid JSON).\n{details}") raise RuntimeError( "Agent was unable to provide valid JSON output after retry.\n" + details ) from None + if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot): + retry_prompt = _leadbook_retry_prompt( + prompt, tick, leadbook_path, leadbook_snapshot["text"], output + ) + leadbook_retry_output = session(retry_prompt) + try: + result = _parse_status(leadbook_retry_output) + except ValueError as exc: + retry_prompt = _json_retry_prompt( + prompt, tick, str(exc), leadbook_retry_output + ) + json_retry_output = session(retry_prompt) + try: + result = _parse_status(json_retry_output) + except ValueError as exc2: + details = _format_json_double_failure( + str(exc), + leadbook_retry_output, + str(exc2), + json_retry_output, + ) + pushover.send(title, f"Lead stopped (invalid JSON).\n{details}") + raise RuntimeError( + "Agent was unable to provide valid JSON output after retry.\n" + + details + ) from None + if not _leadbook_changed(leadbook_path, leadbook_snapshot): + details = _format_leadbook_failure(leadbook_path, output) + pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}") + raise RuntimeError( + "Leadbook was not updated after retry.\n" + details + ) from None last_result = result _print_status(now, elapsed, tick, result) @@ -110,7 +188,7 @@ def watch(minutes, prompt, cwd=None, yolo=True, flags=None): time.sleep(sleep_seconds) -def _build_tick_prompt(prompt, now, elapsed, tick, minutes): +def _build_tick_prompt(prompt, now, elapsed, tick, minutes, leadbook_path, leadbook): lines = [] if tick == 1: @@ -123,13 +201,13 @@ def _build_tick_prompt(prompt, now, elapsed, tick, minutes): lines.extend( [ - f"Tick {tick}.", + f"Check-in {tick}.", f"Local time now: {now}", ] ) if elapsed is not None: lines.append( - "Time since last tick: " + "Time since last check-in: " f"{_format_minutes_seconds(elapsed)} ({int(round(elapsed))}s)" ) lines.extend( @@ -137,10 +215,12 @@ def _build_tick_prompt(prompt, now, elapsed, tick, minutes): "", "A reminder: your instructions are:", prompt.strip(), - "", - _JSON_INSTRUCTIONS, ] ) + leadbook_block = _leadbook_block(leadbook_path, leadbook) + if leadbook_block: + lines.extend(["", leadbook_block]) + lines.extend(["", _JSON_INSTRUCTIONS]) return "\n".join(lines).strip() @@ -186,7 +266,7 @@ def _parse_status(output): def _json_retry_prompt(prompt, tick, error, output): snippet = _snippet(output, 600) lines = [ - f"Your last message (tick {tick}) was not valid JSON.", + f"Your last message (check-in {tick}) was not valid JSON.", f"Error: {error}", "", "Here is your previous output (truncated):", @@ -202,15 +282,15 @@ def _json_retry_prompt(prompt, tick, error, output): def _format_title(prompt): - text = _single_line(prompt).strip() or "codexapi watch" + text = _single_line(prompt).strip() or "codexapi lead" if len(text) > 60: text = text[:57] + "..." - return f"Watch: {text}" + return f"Lead: {text}" def _format_stop_message(tick, now, result): status = _single_line(result.get("status") or "").strip() - header = f"Watch stopped at tick {tick} ({now})." + header = f"Lead stopped at check-in {tick} ({now})." if status: header = f"{header} {status}" comments = (result.get("comments") or "").strip() @@ -219,6 +299,100 @@ def _format_stop_message(tick, now, result): return header +def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): + snippet = _snippet(output, 600) + lines = [ + f"Your last message (check-in {tick}) did not update the leadbook.", + f"Leadbook path: {path}", + "", + "Here is your previous output (truncated):", + snippet, + "", + "Please update the leadbook and then respond with JSON only.", + "Return a fresh status update in the required JSON format.", + "If you want to ask the user a question, put it in comments.", + "", + _leadbook_block(path, leadbook), + "", + _JSON_INSTRUCTIONS, + ] + return "\n".join(lines).strip() + + +def _leadbook_block(path, leadbook): + if not path: + return "" + snippet = _snippet(leadbook, 2000) + return "\n".join( + [ + f"Leadbook path: {path}", + _LEADBOOK_INSTRUCTIONS, + "", + "Leadbook (latest):", + snippet, + ] + ) + + +def _resolve_leadbook_path(leadbook, cwd): + if leadbook is False: + return None + if leadbook is None: + base = cwd or os.getcwd() + return os.path.join(base, "LEADBOOK.md") + if not isinstance(leadbook, str) or not leadbook.strip(): + raise ValueError("leadbook must be a non-empty string or False") + path = os.path.expanduser(leadbook) + if not os.path.isabs(path): + base = cwd or os.getcwd() + path = os.path.join(base, path) + return path + + +def _ensure_leadbook(path): + if os.path.exists(path): + return + directory = os.path.dirname(path) + if directory and not os.path.exists(directory): + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(_LEADBOOK_TEMPLATE) + + +def _snapshot_leadbook(path): + if not path: + return {"hash": None, "text": ""} + try: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + except FileNotFoundError: + text = "" + return {"hash": _hash_text(text), "text": text} + + +def _leadbook_changed(path, snapshot): + if not path: + return True + current = _snapshot_leadbook(path) + return current["hash"] != snapshot["hash"] + + +def _hash_text(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _format_leadbook_failure(path, output): + snippet = _snippet(output, 600) + return "\n".join( + [ + f"Leadbook path: {path}", + "", + "Last output (truncated):", + snippet, + ] + ).strip() + + def _format_json_failure(error, output): snippet = _snippet(output, 600) return "\n".join( @@ -295,7 +469,7 @@ def _print_status(now, elapsed, tick, result): delta = f" +{_format_minutes_seconds(elapsed)}" status = result.get("status", "") cont = result.get("continue") - line = f"[watch {tick} {now}{delta}] {status} (continue={cont})".rstrip() + line = f"[lead {tick} {now}{delta}] {status} (continue={cont})".rstrip() print(line) comments = result.get("comments") or "" if comments.strip(): diff --git a/src/codexapi/pushover.py b/src/codexapi/pushover.py index 9ea8b77..6dea2e5 100644 --- a/src/codexapi/pushover.py +++ b/src/codexapi/pushover.py @@ -15,7 +15,7 @@ _MAX_MESSAGE = 1024 _STARTUP_MESSAGE = ( - "Pushover user and app keys read, notifications for task/science/watch enabled." + "Pushover user and app keys read, notifications for task/science/lead enabled." ) diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 0d72d0c..1a1697f 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -21,7 +21,7 @@ "Return only JSON with keys: success (boolean) and reason (string).\n" "Set success to true only if everything matches the intent." ) -_CHECK_SUFFIX = "JSON only. No markdown or extra text." +_CHECK_SUFFIX = "Return only JSON with keys: success (boolean) and reason (string)." _ESTIMATE_PROMPT = ( "Estimate remaining work in story points for the task below.\n" "You may inspect the repo (read files, git status/diff), but do not run tests.\n" From 2c58133d61291d27c4ce0e70e3dfc83b1a5acf05 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 17 Feb 2026 08:49:40 +0100 Subject: [PATCH 41/78] Allow lead 0 for immediate loops --- README.md | 6 ++++++ src/codexapi/cli.py | 6 +++--- src/codexapi/lead.py | 15 ++++++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fccc67f..22c453b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,12 @@ If the leadbook does not exist, lead creates it with a template. ```bash codexapi lead 5 "Run the benchmark and wait for results." + +Run without waiting between check-ins: + +```bash +codexapi lead 0 "Do a rapid triage pass and report." +``` ``` Ralph loop mode repeats the same prompt until a completion promise or a max diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 6d0794c..f2fad17 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1061,7 +1061,7 @@ def main(argv=None): lead_parser.add_argument( "minutes", type=int, - help="Check-in interval in minutes (integer, >= 1).", + help="Check-in interval in minutes (integer, >= 0).", ) lead_parser.add_argument( "prompt", @@ -1585,8 +1585,8 @@ def main(argv=None): )() return if args.command == "lead": - if args.minutes < 1: - raise SystemExit("lead minutes must be >= 1.") + if args.minutes < 0: + raise SystemExit("lead minutes must be >= 0.") try: if args.no_leadbook and args.leadbook: raise SystemExit("--leadbook and --no-leadbook are mutually exclusive.") diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index bf11d24..989d8d3 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -72,7 +72,7 @@ def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): """Run a periodic lead loop. Args: - minutes: Check-in interval in whole minutes (>= 1). + minutes: Check-in interval in whole minutes (>= 0). prompt: The original instruction prompt. cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. @@ -84,8 +84,8 @@ def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): """ if not isinstance(minutes, int): raise TypeError("minutes must be an integer") - if minutes < 1: - raise ValueError("minutes must be >= 1") + if minutes < 0: + raise ValueError("minutes must be >= 0") if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -182,10 +182,11 @@ def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): pushover.send(title, _format_stop_message(tick, now, result)) return last_result - next_tick = sent_at + interval - sleep_seconds = next_tick - time.monotonic() - if sleep_seconds > 0: - time.sleep(sleep_seconds) + if interval > 0: + next_tick = sent_at + interval + sleep_seconds = next_tick - time.monotonic() + if sleep_seconds > 0: + time.sleep(sleep_seconds) def _build_tick_prompt(prompt, now, elapsed, tick, minutes, leadbook_path, leadbook): From 0f20c83d4a3454d7e060e423fb2f22ba280d4b10 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 17 Feb 2026 08:59:38 +0100 Subject: [PATCH 42/78] Polish lead example and bump version --- .gitignore | 4 ++++ examples/lead/README.md | 25 +++++-------------------- examples/lead/clean.sh | 9 +++++++++ examples/lead/prompt.txt | 4 ---- examples/lead/run_example.sh | 2 +- examples/lead/start_worker.sh | 4 +++- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 8 files changed, 24 insertions(+), 28 deletions(-) create mode 100755 examples/lead/clean.sh diff --git a/.gitignore b/.gitignore index 612bdfc..a0f48aa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist/ .venv/ .env .DS_Store + +# Lead example artifacts +examples/lead/state/ +examples/lead/LEADBOOK.md diff --git a/examples/lead/README.md b/examples/lead/README.md index c74c213..01f78ab 100644 --- a/examples/lead/README.md +++ b/examples/lead/README.md @@ -1,33 +1,18 @@ # Lead Example: Blocked Worker -This example creates a tiny process that should complete and write a done marker. +This example creates a tiny process that starts blocked and will not finish until the lead resolves it. The lead's job is to observe, diagnose, and drive it to completion. ## Run It -1. Start the worker: - -```bash -cd ./examples/lead -./start_worker.sh -``` - -2. In another terminal, start the lead loop: - -```bash -cd ./examples/lead -codexapi lead 1 -f prompt.txt -``` - -Or run both steps in one go: - ```bash ./examples/lead/run_example.sh ``` Notes: - `codexapi lead` will create `examples/lead/LEADBOOK.md` automatically. +- Clean artifacts with `./examples/lead/clean.sh`. -The lead should find the worker's log in `examples/lead/state/worker.log` and -figure out what needs to happen for completion. A successful run results in a -`worker.done` file. +The lead should find the worker's log in `examples/lead/state/worker.log`, see +that it is blocked, and figure out what needs to happen for completion. A +successful run results in a `worker.done` file. diff --git a/examples/lead/clean.sh b/examples/lead/clean.sh new file mode 100755 index 0000000..b385e68 --- /dev/null +++ b/examples/lead/clean.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +rm -rf "$root/state" +rm -f "$root/LEADBOOK.md" + +echo "Cleaned lead example artifacts." diff --git a/examples/lead/prompt.txt b/examples/lead/prompt.txt index ae77e1d..88e3441 100644 --- a/examples/lead/prompt.txt +++ b/examples/lead/prompt.txt @@ -1,6 +1,2 @@ -You are leading a tiny system from a control room. - A worker was started by examples/lead/start_worker.sh. It should complete and write a done marker. Your job is to make that happen without editing the worker code unless absolutely necessary. - -Use the logs and state files in examples/lead/state to diagnose. Confirm completion. diff --git a/examples/lead/run_example.sh b/examples/lead/run_example.sh index 862c013..93f26c7 100755 --- a/examples/lead/run_example.sh +++ b/examples/lead/run_example.sh @@ -3,7 +3,7 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -"$root/start_worker.sh" +LEAD_EXAMPLE_QUIET=1 "$root/start_worker.sh" echo "Starting codexapi lead..." cd "$root" diff --git a/examples/lead/start_worker.sh b/examples/lead/start_worker.sh index 45b1761..ae41538 100755 --- a/examples/lead/start_worker.sh +++ b/examples/lead/start_worker.sh @@ -16,7 +16,8 @@ echo $! > "$state/worker.pid" echo "Worker pid: $(cat "$state/worker.pid")" -cat < Date: Tue, 17 Feb 2026 16:38:41 +0100 Subject: [PATCH 43/78] Fix agent output defaults and bump version --- README.md | 8 ++++++-- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 21 +++++++++++++++------ src/codexapi/cli.py | 10 +++++++++- src/codexapi/ralph.py | 19 +++++++++++++++++-- src/codexapi/science.py | 1 + 7 files changed, 50 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 22c453b..b554ea2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off ``` Use `--no-yolo` to run Codex with `--full-auto` instead. +Use `--include-thinking` to return all agent messages joined together for `codexapi run`. Lead mode periodically checks in on a long-running agent session with the current time and prints JSON status updates. The agent controls the loop by @@ -184,7 +185,7 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False) -> str` Runs a single Codex turn and returns only the agent's message. Any reasoning items are filtered out. @@ -193,8 +194,9 @@ items are filtered out. - `cwd` (str | PathLike | None): working directory for the Codex session. - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to Codex. +- `include_thinking` (bool): when true, return all agent messages joined. -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -205,6 +207,7 @@ the same conversation and returns only the agent's message. - `flags` (str | None): extra CLI flags to pass to Codex. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. +- `include_thinking` (bool): when true, return all agent messages joined. ### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None) -> dict` @@ -290,6 +293,7 @@ Simple result object returned by `foreach()`. ## Behavior notes - Uses `codex exec --json` and parses JSONL events for `agent_message` items. +- Returns the last `agent_message` by default; set `include_thinking=True` to join all messages. - Automatically passes `--skip-git-repo-check` so it can run outside a git repo. - Passes `--yolo` by default (use `--no-yolo` or `yolo=False` for `--full-auto`). - Raises `RuntimeError` if Codex exits non-zero or returns no agent message. diff --git a/pyproject.toml b/pyproject.toml index 3e1f695..8496751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.7.0" +version = "0.7.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index ca79389..da4d432 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index ad86233..5f65c34 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -10,7 +10,7 @@ _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") -def agent(prompt, cwd=None, yolo=True, flags=None): +def agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False): """Run a single Codex turn and return only the agent's message. Args: @@ -18,11 +18,14 @@ def agent(prompt, cwd=None, yolo=True, flags=None): cwd: Optional working directory for the Codex session. yolo: Whether to pass --yolo to Codex. flags: Additional raw CLI flags to pass to Codex. + include_thinking: When true, return all agent messages joined together. Returns: The agent's visible response text with reasoning traces removed. """ - message, _thread_id = _run_codex(prompt, cwd, None, yolo, flags) + message, _thread_id = _run_codex( + prompt, cwd, None, yolo, flags, include_thinking + ) return message @@ -51,6 +54,7 @@ def __init__( thread_id=None, flags=None, welfare=False, + include_thinking=False, ): """Create a new session wrapper. @@ -62,11 +66,13 @@ def __init__( flags: Additional raw CLI flags to pass to Codex. welfare: When true, append welfare stop instructions to each prompt and raise WelfareStop if the agent outputs MAKE IT STOP. + include_thinking: When true, return all agent messages joined together. """ self.cwd = cwd self._yolo = yolo self._flags = flags self._welfare = welfare + self._include_thinking = include_thinking self.thread_id = thread_id def __call__(self, prompt): @@ -79,6 +85,7 @@ def __call__(self, prompt): self.thread_id, self._yolo, self._flags, + self._include_thinking, ) if thread_id: self.thread_id = thread_id @@ -87,7 +94,7 @@ def __call__(self, prompt): return message -def _run_codex(prompt, cwd, thread_id, yolo, flags): +def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -124,10 +131,10 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags): msg = f"{msg}\n{stderr}" raise RuntimeError(msg) - return _parse_jsonl(result.stdout) + return _parse_jsonl(result.stdout, include_thinking) -def _parse_jsonl(output): +def _parse_jsonl(output, include_thinking): """Extract agent messages and the latest thread id from Codex JSONL output.""" thread_id = None messages = [] @@ -161,4 +168,6 @@ def _parse_jsonl(output): "Codex returned no agent message. Raw output:\n" + fallback ) - return "\n\n".join(messages), thread_id + if include_thinking: + return "\n\n".join(messages), thread_id + return messages[-1], thread_id diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f2fad17..16ea1e0 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1053,6 +1053,11 @@ def main(argv=None): "--flags", help="Additional raw CLI flags to pass to Codex (quoted as needed).", ) + run_parser.add_argument( + "--include-thinking", + action="store_true", + help="Return all agent messages joined together.", + ) lead_parser = subparsers.add_parser( "lead", @@ -1637,12 +1642,15 @@ def main(argv=None): args.yolo, args.thread_id, args.flags, + include_thinking=args.include_thinking, ) message = session(prompt) if args.print_thread_id: print(f"thread_id={session.thread_id}", file=sys.stderr) else: - message = agent(prompt, args.cwd, args.yolo, args.flags) + message = agent( + prompt, args.cwd, args.yolo, args.flags, args.include_thinking + ) if message is not None: print(message) diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 9273bf7..879f28a 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -41,6 +41,7 @@ def __init__( self.max_iterations = max_iterations self.completion_promise = completion_promise self.fresh = fresh + self.include_thinking = True def hook_before_loop(self): """Hook called once before the loop starts.""" @@ -156,9 +157,23 @@ def __call__(self): self.hook_before_iteration(iteration) if self.fresh: - runner = Agent(self.cwd, self.yolo, None, self.flags, welfare=True) + runner = Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + ) elif runner is None: - runner = Agent(self.cwd, self.yolo, None, self.flags, welfare=True) + runner = Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + ) prompt = self.build_prompt(iteration) stopped = False diff --git a/src/codexapi/science.py b/src/codexapi/science.py index 02a52a1..44db4aa 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -110,6 +110,7 @@ def __init__( completion_promise, fresh, ) + self.include_thinking = True self._prompt_a = prompt_a self._prompt_b = prompt_b self._logbook_path = _logbook_path(cwd) From 99f2f940630263b05c5407ad758c41a8575aacdb Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 28 Feb 2026 00:22:55 +0100 Subject: [PATCH 44/78] Add science max-duration and final pushover status; bump v0.7.2 --- README.md | 10 ++++- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 33 ++++++++++++++ src/codexapi/science.py | 93 ++++++++++++++++++++++++++++++++++++-- tests/test_science.py | 97 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 tests/test_science.py diff --git a/README.md b/README.md index b554ea2..15b531f 100644 --- a/README.md +++ b/README.md @@ -160,17 +160,23 @@ codexapi ralph --cancel --cwd /path/to/project Science mode wraps a short task in a science prompt and runs it through the Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts -any improved figures of merit for optional notifications. +any improved figures of merit for optional notifications. You can also set +`--max-duration` to stop after the current iteration once a time limit is hit. +The default science wrapper also tells the agent to create/use a local git +branch when in a repo and make local commits for worthwhile improvements, while +never committing or resetting `LOGBOOK.md` or `SCIENCE.md`. ```bash codexapi science "hyper-optimize the kernel cycles" codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3 +codexapi science "hyper-optimize the kernel cycles" --max-duration 90m ``` Optional Pushover notifications: create `~/.pushover` with two non-empty lines. Line 1 is your user or group key, line 2 is the app API token. When this file exists, Science will send a notification whenever it detects a new best result, -including the metric values and percent improvement. Task runs will also send a +including the metric values and percent improvement, plus a final run-end status. +Task runs will also send a ✅/❌ notification with the task summary. Lead runs send a notification when the loop stops. diff --git a/pyproject.toml b/pyproject.toml index 8496751..95c1fae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.7.1" +version = "0.7.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index da4d432..aebda1f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.7.1" +__version__ = "0.7.2" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 16ea1e0..4b9fd33 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -24,6 +24,7 @@ _SESSION_ID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" ) +_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)([smhdSMHD]?)\s*$") _TAIL_BYTES = 256 * 1024 _TAIL_MAX_BYTES = 4 * 1024 * 1024 _TAIL_MIN_LINES = 200 @@ -92,6 +93,25 @@ def _read_prompt(prompt): return data +def _parse_duration_seconds(value, flag_name): + if value is None: + return 0.0 + text = str(value).strip() + if not text: + raise SystemExit(f"{flag_name} cannot be empty.") + match = _DURATION_RE.match(text) + if not match: + raise SystemExit( + f"{flag_name} must be a number with optional unit s/m/h/d (example: 90m)." + ) + amount = float(match.group(1)) + unit = (match.group(2) or "m").lower() + if amount < 0: + raise SystemExit(f"{flag_name} must be >= 0.") + multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] + return amount * multiplier + + def _read_prompt_file(path): if not path or not str(path).strip(): raise SystemExit("Prompt file path is empty.") @@ -1026,6 +1046,8 @@ def main(argv=None): "Science mode (science command):\n" " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" " Default uses --yolo. Use --no-yolo to run --full-auto instead.\n" + " Optional --max-duration stops before starting the next iteration once\n" + " the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n" ) parser = argparse.ArgumentParser( prog="codexapi", @@ -1255,6 +1277,13 @@ def main(argv=None): default=0, help="Max iterations for the loop (0 means unlimited).", ) + science_parser.add_argument( + "--max-duration", + help=( + "Maximum loop runtime. Stops after the current iteration when reached. " + "Accepts s/m/h/d units (e.g. 90m, 2h, 45s); default unit is minutes." + ), + ) science_parser.add_argument( "--cancel", action="store_true", @@ -1440,6 +1469,8 @@ def main(argv=None): ) if args.max_iterations != 0: raise SystemExit("--max-iterations is not allowed with --cancel.") + if args.max_duration: + raise SystemExit("--max-duration is not allowed with --cancel.") print(cancel_ralph_loop(args.cwd)) return if args.ralph_fresh is None: @@ -1579,6 +1610,7 @@ def main(argv=None): if args.command == "science": if args.max_iterations < 0: raise SystemExit("--max-iterations must be >= 0.") + max_duration_seconds = _parse_duration_seconds(args.max_duration, "--max-duration") Science( prompt, args.cwd, @@ -1587,6 +1619,7 @@ def main(argv=None): args.max_iterations, args.completion_promise, args.ralph_fresh, + max_duration_seconds, )() return if args.command == "lead": diff --git a/src/codexapi/science.py b/src/codexapi/science.py index 44db4aa..296377b 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -3,6 +3,7 @@ import json import os import sys +import time from datetime import datetime, timezone from .agent import agent @@ -29,7 +30,10 @@ "Try your best and have fun with this one! If you " "think of several options, pick one and run with it - I will not be available " "to make decisions for you, I give you my full permission to explore and make " - "your own best judgement towards our goal! Remember to update SCIENCE.md. " + "your own best judgement towards our goal! If you are in a git repository, " + "create and use a local branch for this run. Make local commits for improvements " + "worth keeping, but never commit or reset LOGBOOK.md or SCIENCE.md. " + "Remember to update SCIENCE.md. " "Good hunting!" ) _LOGBOOK_NAME = "LOGBOOK.md" @@ -97,7 +101,10 @@ def __init__( max_iterations=0, completion_promise=None, fresh=True, + max_duration_seconds=0, ): + if max_duration_seconds < 0: + raise ValueError("max_duration_seconds must be >= 0") self._task = task.strip() if isinstance(task, str) else task prompt_a, prompt_b = _science_parts(task) prompt = f"{prompt_a}{prompt_b}" @@ -117,11 +124,20 @@ def __init__( self._best_metrics = None self._run_title = None self._pushover = Pushover() + self._pushover_enabled = False + self._max_duration_seconds = float(max_duration_seconds) + self._loop_started_monotonic = None + self._duration_limit_hit = False + self._last_iteration = 0 def hook_before_loop(self): super().hook_before_loop() - self._pushover.ensure_ready() - self._run_title = self._build_run_title() + self._loop_started_monotonic = time.monotonic() + self._pushover_enabled = self._pushover.ensure_ready() + if self._pushover_enabled: + self._run_title = self._build_run_title() + else: + self._run_title = _fallback_title(self._task) def build_prompt(self, iteration): if iteration <= 1: @@ -131,8 +147,33 @@ def build_prompt(self, iteration): def hook_after_iteration(self, iteration, message): super().hook_after_iteration(iteration, message) + self._last_iteration = iteration self._append_logbook(iteration, message) self._extract_and_notify(message) + self._mark_duration_stop(iteration) + + def hook_after_loop(self, last_message, stop_reason): + super().hook_after_loop(last_message, stop_reason) + if not self._pushover_enabled: + return + status = _format_final_status( + stop_reason, + self.max_iterations, + self.completion_promise, + self._duration_limit_hit, + ) + lines = [ + f"Science run ended: {status}", + f"Iterations completed: {self._last_iteration}", + ] + if self._best_metrics: + summary = _single_line(self._best_metrics.get("summary", "")).strip() + metrics_text = _format_metrics(self._best_metrics.get("metrics") or []) + if summary: + lines.append(f"Best summary: {summary}") + if metrics_text: + lines.append(f"Best metrics: {metrics_text}") + self._pushover.send(self._run_title, "\n".join(lines)) def hook_new_best(self, result): super().hook_new_best(result) @@ -186,6 +227,25 @@ def _build_run_title(self): title = _fallback_title(self._task) return title + def _mark_duration_stop(self, iteration): + if self._duration_limit_hit: + return + if self._max_duration_seconds <= 0: + return + if self._loop_started_monotonic is None: + return + elapsed = time.monotonic() - self._loop_started_monotonic + if elapsed < self._max_duration_seconds: + return + self._duration_limit_hit = True + self.max_iterations = ( + iteration if self.max_iterations == 0 else min(self.max_iterations, iteration) + ) + print( + "Science loop: Max duration reached; " + "stopping after the current iteration." + ) + def _build_metrics_prompt(task, message, previous_best): @@ -300,3 +360,30 @@ def _fallback_title(task): def _warn(message): print(message, file=sys.stderr) + + +def _format_final_status( + stop_reason, + max_iterations, + completion_promise, + duration_limit_hit, +): + if stop_reason == "max_iterations": + if duration_limit_hit: + return "max duration reached" + return f"max iterations reached ({max_iterations})" + if stop_reason == "promise": + if completion_promise: + return f"completion promise met ({completion_promise})" + return "completion promise met" + if stop_reason == "welfare_stop": + return "agent requested welfare stop" + if stop_reason == "canceled": + return "loop canceled" + if stop_reason == "interrupted": + return "interrupted" + if stop_reason == "error": + return "stopped due to error" + if stop_reason: + return _single_line(stop_reason) + return "finished" diff --git a/tests/test_science.py b/tests/test_science.py new file mode 100644 index 0000000..538e246 --- /dev/null +++ b/tests/test_science.py @@ -0,0 +1,97 @@ +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.science import Science, _science_parts + + +class _FakePushover: + def __init__(self, enabled): + self.enabled = enabled + self.sent = [] + + def ensure_ready(self, announce=True): + return self.enabled + + def send(self, title, message): + self.sent.append((title, message)) + return True + + +class _TestScience(Science): + def _append_logbook(self, iteration, message): + return None + + def _extract_and_notify(self, message): + return None + + def _build_run_title(self): + return "test-run" + + +class _FakeAgent: + calls = 0 + + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + welfare=False, + include_thinking=False, + ): + pass + + def __call__(self, prompt): + _FakeAgent.calls += 1 + return f"message {_FakeAgent.calls}" + + +class ScienceTests(unittest.TestCase): + def test_science_prompt_includes_git_commit_guidance(self): + _prompt_a, prompt_b = _science_parts("improve performance") + self.assertIn("create and use a local branch", prompt_b) + self.assertIn("never commit or reset LOGBOOK.md or SCIENCE.md", prompt_b) + + def test_max_duration_stops_after_current_iteration(self): + _FakeAgent.calls = 0 + with tempfile.TemporaryDirectory() as tmpdir: + runner = _TestScience( + "improve performance", + cwd=tmpdir, + max_duration_seconds=60, + ) + runner._pushover = _FakePushover(enabled=False) + with patch("codexapi.ralph.Agent", _FakeAgent): + with patch("codexapi.science.time.monotonic", side_effect=[0, 30, 61]): + runner() + self.assertEqual(_FakeAgent.calls, 2) + self.assertTrue(runner._duration_limit_hit) + self.assertEqual(runner._last_iteration, 2) + + def test_final_pushover_update_sent_when_enabled(self): + _FakeAgent.calls = 0 + with tempfile.TemporaryDirectory() as tmpdir: + runner = _TestScience( + "improve performance", + cwd=tmpdir, + max_iterations=1, + ) + fake_pushover = _FakePushover(enabled=True) + runner._pushover = fake_pushover + with patch("codexapi.ralph.Agent", _FakeAgent): + runner() + self.assertEqual(len(fake_pushover.sent), 1) + title, message = fake_pushover.sent[0] + self.assertEqual(title, "test-run") + self.assertIn("Science run ended: max iterations reached (1)", message) + self.assertIn("Iterations completed: 1", message) + + +if __name__ == "__main__": + unittest.main() From 658c8546568e815a7b68f87e03314042db83d169 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 28 Feb 2026 16:45:22 +0100 Subject: [PATCH 45/78] Prefer codex rate limit bucket over spark --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/rate_limits.py | 17 ++++++++- tests/test_rate_limits.py | 71 +++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tests/test_rate_limits.py diff --git a/pyproject.toml b/pyproject.toml index 95c1fae..9be2bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.7.2" +version = "0.7.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index aebda1f..923ee6e 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.7.2" +__version__ = "0.7.3" diff --git a/src/codexapi/rate_limits.py b/src/codexapi/rate_limits.py index 8f4989c..f94d517 100644 --- a/src/codexapi/rate_limits.py +++ b/src/codexapi/rate_limits.py @@ -36,6 +36,7 @@ def rate_limits(): def _extract_rate_limits(path): last = None + preferred = None try: with open(path, "r", encoding="utf-8", errors="replace") as handle: for line in handle: @@ -49,9 +50,23 @@ def _extract_rate_limits(path): rate_data = payload.get("rate_limits") if isinstance(rate_data, dict): last = rate_data + if _is_primary_limit(rate_data): + preferred = rate_data except OSError: return None - return last + return preferred or last + + +def _is_primary_limit(rate_data): + limit_id = rate_data.get("limit_id") + if isinstance(limit_id, str): + return limit_id == "codex" + limit_name = rate_data.get("limit_name") + if limit_name is None: + return True + if isinstance(limit_name, str): + return not limit_name.strip() + return False def quota_line(): diff --git a/tests/test_rate_limits.py b/tests/test_rate_limits.py new file mode 100644 index 0000000..f617257 --- /dev/null +++ b/tests/test_rate_limits.py @@ -0,0 +1,71 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.rate_limits import _extract_rate_limits + + +def _write_events(path, rates): + with open(path, "w", encoding="utf-8") as handle: + for rate in rates: + event = {"payload": {"rate_limits": rate}} + handle.write(json.dumps(event)) + handle.write("\n") + + +class RateLimitsTests(unittest.TestCase): + def test_extract_prefers_codex_limit_id_when_spark_is_last(self): + with tempfile.NamedTemporaryFile(delete=False) as handle: + path = handle.name + try: + _write_events( + path, + [ + {"limit_id": "codex", "primary": {"used_percent": 3}}, + { + "limit_id": "codex_bengalfox", + "limit_name": "GPT-5.3-Codex-Spark", + "primary": {"used_percent": 0}, + }, + ], + ) + found = _extract_rate_limits(path) + finally: + os.unlink(path) + self.assertEqual(found["limit_id"], "codex") + self.assertEqual(found["primary"]["used_percent"], 3) + + def test_extract_falls_back_to_last_when_codex_missing(self): + with tempfile.NamedTemporaryFile(delete=False) as handle: + path = handle.name + try: + _write_events( + path, + [ + {"limit_id": "codex_bengalfox", "primary": {"used_percent": 1}}, + {"limit_id": "codex_lynx", "primary": {"used_percent": 2}}, + ], + ) + found = _extract_rate_limits(path) + finally: + os.unlink(path) + self.assertEqual(found["limit_id"], "codex_lynx") + + def test_extract_keeps_legacy_single_limit_without_limit_id(self): + with tempfile.NamedTemporaryFile(delete=False) as handle: + path = handle.name + try: + _write_events(path, [{"primary": {"used_percent": 22}}]) + found = _extract_rate_limits(path) + finally: + os.unlink(path) + self.assertEqual(found["primary"]["used_percent"], 22) + + +if __name__ == "__main__": + unittest.main() From c38d88435fd02e4424f72c3ba7be2ad80abcf7c0 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 3 Mar 2026 18:19:36 +0100 Subject: [PATCH 46/78] Add cursor backend support --- README.md | 85 ++++++++++++------- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 148 +++++++++++++++++++++++++++++---- src/codexapi/cli.py | 105 +++++++++++++++++------ src/codexapi/foreach.py | 4 + src/codexapi/gh_integration.py | 5 +- src/codexapi/lead.py | 23 +++-- src/codexapi/ralph.py | 6 +- src/codexapi/science.py | 7 +- src/codexapi/task.py | 54 +++++++++--- src/codexapi/taskfile.py | 3 + tests/test_science.py | 1 + tests/test_task_progress.py | 15 ++-- 13 files changed, 357 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 15b531f..9e9472a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # CodexAPI -Use OpenAI's codex from python as easily as calling a function with your codex credits instead of the API. +Use Codex or Cursor agents from python as easily as calling a function, using your CLI auth instead of the API. *Note: this project is not affiliated with OpenAI in any way. Thanks for the awesome tools and models though!* ## Requirements -- Codex CLI installed and authenticated (`codex` must be on your PATH). +- Codex CLI installed and authenticated (`codex` must be on your PATH), or +- Cursor Agent CLI installed and authenticated (`cursor` must be on your PATH). - Python 3.8+. ## Install @@ -44,6 +45,9 @@ result = task() print(result.success, result.summary) ``` +Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the +Cursor agent backend. + ## CLI After installing, use the `codexapi` command: @@ -52,6 +56,7 @@ After installing, use the `codexapi` command: codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run +codexapi run --backend cursor "Summarize this repo." ``` `codexapi task` exits with code 0 on success and 1 on failure. @@ -107,15 +112,16 @@ Show running sessions and their latest activity: codexapi top ``` Press `h` for keys. +`codexapi top` and `codexapi limit` are Codex-only. -Resume a session and print the thread id to stderr: +Resume a session and print the thread/session id to stderr: ```bash codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` -Use `--no-yolo` to run Codex with `--full-auto` instead. -Use `--include-thinking` to return all agent messages joined together for `codexapi run`. +Use `--no-yolo` to disable `--yolo` (Codex uses `--full-auto`). +Use `--include-thinking` to return all agent messages joined together for `codexapi run` (Codex only). Lead mode periodically checks in on a long-running agent session with the current time and prints JSON status updates. The agent controls the loop by @@ -191,31 +197,34 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None) -> str` -Runs a single Codex turn and returns only the agent's message. Any reasoning +Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. -- `prompt` (str): prompt to send to Codex. -- `cwd` (str | PathLike | None): working directory for the Codex session. -- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). -- `flags` (str | None): extra CLI flags to pass to Codex. +- `prompt` (str): prompt to send to the agent backend. +- `cwd` (str | PathLike | None): working directory for the agent session. +- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. +- `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. -- `__call__(prompt) -> str`: send a prompt to Codex and return the message. +- `__call__(prompt) -> str`: send a prompt to the agent backend and return the message. - `thread_id -> str | None`: expose the underlying session id once created. -- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). -- `flags` (str | None): extra CLI flags to pass to Codex. +- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `flags` (str | None): extra CLI flags to pass to the agent backend. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. - `include_thinking` (bool): when true, return all agent messages joined. +- `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. -### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None) -> dict` +### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None) -> dict` Runs a long-lived agent session and periodically checks in with the current local time and a reminder of `prompt`. Each check-in expects JSON with keys: @@ -226,8 +235,9 @@ JSON is invalid, lead asks the agent once to retry. The loop stops when Lead also injects the leadbook content into each prompt. By default it uses `LEADBOOK.md` in the working directory. Pass `leadbook=False` to disable or a path string to override the location. +Set `backend="cursor"` (or `CODEXAPI_BACKEND=cursor`) to use Cursor. -### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str` +### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum iterations are reached. @@ -236,16 +246,17 @@ Raises `TaskFailed` when the maximum iterations are reached. - `max_iterations` (int): maximum number of task iterations (0 means unlimited). - `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. +- `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). -### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> TaskResult` +### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. Arguments mirror `task()` (including hooks). -### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None)` -Runs a Codex task with checker-driven retries. Subclass it and implement +Runs an agent task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return `None`/`""` when the task passes. If you do not override `check()`, the default verifier wrapper runs with the @@ -266,7 +277,7 @@ Simple result object returned by `Task.__call__`. - `summary` (str): agent summary of what happened. - `iterations` (int): how many iterations were used. - `errors` (str | None): last checker error, if any. -- `thread_id` (str | None): Codex thread id for the session. +- `thread_id` (str | None): thread/session id for the session. ### `TaskFailed` @@ -276,16 +287,17 @@ Exception raised by `task()` when iterations are exhausted. - `iterations` (int | None): iterations made when the task failed. - `errors` (str | None): last checker error, if any. -### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult` +### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None) -> ForeachResult` Runs a task file over a list of items, updating the list file in place. - `list_file` (str | PathLike): path to the list file to process. - `task_file` (str | PathLike): YAML task file (must include `prompt`). - `n` (int | None): limit parallelism to N (default: run all items in parallel). -- `cwd` (str | PathLike | None): working directory for the Codex session. -- `yolo` (bool): pass `--yolo` to Codex when true (defaults to true). -- `flags` (str | None): extra CLI flags to pass to Codex. +- `cwd` (str | PathLike | None): working directory for the agent session. +- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `flags` (str | None): extra CLI flags to pass to the agent backend. +- `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). ### `ForeachResult(succeeded, failed, skipped, results)` @@ -298,16 +310,29 @@ Simple result object returned by `foreach()`. ## Behavior notes -- Uses `codex exec --json` and parses JSONL events for `agent_message` items. -- Returns the last `agent_message` by default; set `include_thinking=True` to join all messages. -- Automatically passes `--skip-git-repo-check` so it can run outside a git repo. -- Passes `--yolo` by default (use `--no-yolo` or `yolo=False` for `--full-auto`). -- Raises `RuntimeError` if Codex exits non-zero or returns no agent message. +- Codex backend uses `codex exec --json` and parses JSONL `agent_message` items. +- Codex backend passes `--skip-git-repo-check` so it can run outside a git repo. +- Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. +- `include_thinking=True` only affects Codex; Cursor returns a single result string. +- Passes `--yolo` by default (Codex uses `--full-auto` when disabled). +- Raises `RuntimeError` if the backend exits non-zero or returns no agent message. ## Configuration +Set the default backend: + +```bash +export CODEXAPI_BACKEND=cursor +``` + Set `CODEX_BIN` to point at a non-default Codex binary: ```bash export CODEX_BIN=/path/to/codex ``` + +Set `CURSOR_BIN` to point at a non-default Cursor binary: + +```bash +export CURSOR_BIN=/path/to/cursor +``` diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 923ee6e..d3f12d7 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,4 +1,4 @@ -"""Minimal Python API for running the Codex CLI.""" +"""Minimal Python API for running agent CLIs.""" from .agent import Agent, WelfareStop, agent from .foreach import ForeachResult, foreach diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 5f65c34..1e2526c 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -1,4 +1,4 @@ -"""Codex CLI wrapper used by the codexapi public interface.""" +"""Agent CLI wrapper used by the codexapi public interface.""" import json import os @@ -8,23 +8,45 @@ from . import welfare _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") +_CURSOR_BIN = os.environ.get("CURSOR_BIN", "cursor") +_SUPPORTED_BACKENDS = {"codex", "cursor"} -def agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False): - """Run a single Codex turn and return only the agent's message. +def _resolve_backend(backend): + if backend is None: + backend = os.environ.get("CODEXAPI_BACKEND", "codex") + if not isinstance(backend, str): + raise TypeError("backend must be a string") + backend = backend.strip().lower() + if backend not in _SUPPORTED_BACKENDS: + choices = ", ".join(sorted(_SUPPORTED_BACKENDS)) + raise ValueError(f"Unknown backend '{backend}'. Choose one of: {choices}.") + return backend + + +def agent( + prompt, + cwd=None, + yolo=True, + flags=None, + include_thinking=False, + backend=None, +): + """Run a single agent turn and return only the agent's message. Args: - prompt: The user prompt to send to Codex. - cwd: Optional working directory for the Codex session. - yolo: Whether to pass --yolo to Codex. - flags: Additional raw CLI flags to pass to Codex. + prompt: The user prompt to send to the agent backend. + cwd: Optional working directory for the agent session. + yolo: Whether to pass --yolo to the agent backend. + flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. + backend: Agent backend to use ("codex" or "cursor"). Returns: The agent's visible response text with reasoning traces removed. """ - message, _thread_id = _run_codex( - prompt, cwd, None, yolo, flags, include_thinking + message, _thread_id = _run_agent( + prompt, cwd, None, yolo, flags, include_thinking, backend ) return message @@ -39,7 +61,7 @@ def __init__(self, agent_message): class Agent: - """Stateful Codex session wrapper that resumes the same conversation. + """Stateful session wrapper that resumes the same conversation. Example: session = Agent() @@ -55,18 +77,19 @@ def __init__( flags=None, welfare=False, include_thinking=False, + backend=None, ): """Create a new session wrapper. Args: - cwd: Optional working directory for the Codex session. - yolo: Whether to pass --yolo to Codex. - agent: Agent backend to use (only "codex" is supported). - trace_id: Optional Codex thread id to resume from the first call. - flags: Additional raw CLI flags to pass to Codex. + cwd: Optional working directory for the agent session. + yolo: Whether to pass --yolo to the agent backend. + thread_id: Optional thread/session id to resume from the first call. + flags: Additional raw CLI flags to pass to the agent backend. welfare: When true, append welfare stop instructions to each prompt and raise WelfareStop if the agent outputs MAKE IT STOP. include_thinking: When true, return all agent messages joined together. + backend: Agent backend to use ("codex" or "cursor"). """ self.cwd = cwd self._yolo = yolo @@ -74,18 +97,20 @@ def __init__( self._welfare = welfare self._include_thinking = include_thinking self.thread_id = thread_id + self._backend = backend def __call__(self, prompt): - """Send a prompt to Codex and return only the agent's message.""" + """Send a prompt to the agent backend and return the message.""" if self._welfare: prompt = welfare.append_instructions(prompt) - message, thread_id = _run_codex( + message, thread_id = _run_agent( prompt, self.cwd, self.thread_id, self._yolo, self._flags, self._include_thinking, + self._backend, ) if thread_id: self.thread_id = thread_id @@ -94,6 +119,13 @@ def __call__(self, prompt): return message +def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend): + backend = _resolve_backend(backend) + if backend == "codex": + return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking) + return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking) + + def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ @@ -134,6 +166,40 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): return _parse_jsonl(result.stdout, include_thinking) +def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): + """Invoke the Cursor agent CLI and return the message plus session id (if any).""" + command = [ + _CURSOR_BIN, + "agent", + "--trust", + ] + if cwd: + command.extend(["--workspace", os.fspath(cwd)]) + if thread_id: + command.extend(["--resume", thread_id]) + if yolo: + command.append("--yolo") + if flags: + command.extend(shlex.split(flags)) + command.extend(["--print", "--output-format", "json"]) + + result = subprocess.run( + command, + input=prompt, + text=True, + capture_output=True, + cwd=os.fspath(cwd) if cwd else None, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + msg = f"Cursor agent failed with exit code {result.returncode}." + if stderr: + msg = f"{msg}\n{stderr}" + raise RuntimeError(msg) + + return _parse_cursor_json(result.stdout, include_thinking) + + def _parse_jsonl(output, include_thinking): """Extract agent messages and the latest thread id from Codex JSONL output.""" thread_id = None @@ -171,3 +237,51 @@ def _parse_jsonl(output, include_thinking): if include_thinking: return "\n\n".join(messages), thread_id return messages[-1], thread_id + + +def _parse_cursor_json(output, include_thinking): + """Extract the agent message and session id from Cursor JSON output. + + Cursor returns a single result string; include_thinking has no effect. + """ + payload = None + raw_lines = [] + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + try: + decoded = json.loads(line) + except json.JSONDecodeError: + raw_lines.append(line) + continue + if isinstance(decoded, dict): + if "result" in decoded: + payload = decoded + elif payload is None: + payload = decoded + + if payload is None: + fallback = "\n".join(raw_lines) if raw_lines else output.strip() + raise RuntimeError( + "Cursor returned no JSON output. Raw output:\n" + fallback + ) + + if payload.get("is_error"): + message = payload.get("result") + if not isinstance(message, str) or not message.strip(): + message = "Cursor returned an error response." + raise RuntimeError(message) + + result = payload.get("result") + if not isinstance(result, str): + fallback = output.strip() + raise RuntimeError( + "Cursor returned no result text. Raw output:\n" + fallback + ) + + session_id = payload.get("session_id") + if not isinstance(session_id, str): + session_id = None + return result, session_id diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 4b9fd33..f0ac111 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -33,7 +33,7 @@ _TASK_TEMPLATE = ( "prompt: |\n" " Main task prompt. Required. Use {{item}} for per-item values.\n" - " Describe what Codex should do here.\n" + " Describe what the agent should do here.\n" "\n" "set_up: |\n" " Optional setup steps before the task runs.\n" @@ -1040,45 +1040,50 @@ def main(argv=None): " first non-empty line of its message.\n" " Cancel by deleting .codexapi/ralph-loop.local.md or running codexapi ralph --cancel.\n" " Default starts each iteration with a fresh Agent context; use --ralph-reuse\n" - " to reuse a single Codex thread across iterations.\n" + " to reuse a single thread across iterations.\n" ) science_help = ( "Science mode (science command):\n" " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" - " Default uses --yolo. Use --no-yolo to run --full-auto instead.\n" + " Default uses --yolo. Use --no-yolo to disable it.\n" " Optional --max-duration stops before starting the next iteration once\n" " the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n" ) parser = argparse.ArgumentParser( prog="codexapi", - description="Run Codex via the codexapi wrapper.", + description="Run agent backends via the codexapi wrapper.", ) subparsers = parser.add_subparsers(dest="command") run_parser = subparsers.add_parser( "run", - help="Run a Codex prompt.", + help="Run an agent prompt.", ) run_parser.add_argument( "prompt", nargs="?", help="Prompt to send. Use '-' or omit to read from stdin.", ) - run_parser.add_argument("--cwd", help="Working directory for the Codex session.") + run_parser.add_argument("--cwd", help="Working directory for the agent session.") + run_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) run_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) run_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) run_parser.add_argument( "--include-thinking", action="store_true", - help="Return all agent messages joined together.", + help="Return all agent messages joined together (Codex only).", ) lead_parser = subparsers.add_parser( @@ -1100,7 +1105,12 @@ def main(argv=None): "--prompt-file", help="Read the lead prompt from a file.", ) - lead_parser.add_argument("--cwd", help="Working directory for the Codex session.") + lead_parser.add_argument("--cwd", help="Working directory for the agent session.") + lead_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) lead_parser.add_argument( "--leadbook", help="Path to the leadbook file (default: LEADBOOK.md in cwd).", @@ -1114,15 +1124,15 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) lead_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) run_parser.add_argument( "--thread-id", - help="Resume an existing Codex thread id.", + help="Resume an existing thread/session id.", ) run_parser.add_argument( "--print-thread-id", @@ -1185,16 +1195,21 @@ def main(argv=None): f"Defaults to {DEFAULT_MAX_ITERATIONS}." ), ) - task_parser.add_argument("--cwd", help="Working directory for the Codex session.") + task_parser.add_argument("--cwd", help="Working directory for the agent session.") + task_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) task_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) task_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) task_parser.add_argument( "--quiet", @@ -1248,16 +1263,21 @@ def main(argv=None): default=None, help="Reuse the same Agent context each iteration.", ) - ralph_parser.add_argument("--cwd", help="Working directory for the Codex session.") + ralph_parser.add_argument("--cwd", help="Working directory for the agent session.") + ralph_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) ralph_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) ralph_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) science_parser = subparsers.add_parser( @@ -1308,16 +1328,21 @@ def main(argv=None): default=None, help="Reuse the same Agent context each iteration.", ) - science_parser.add_argument("--cwd", help="Working directory for the Codex session.") + science_parser.add_argument("--cwd", help="Working directory for the agent session.") + science_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) science_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) science_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) foreach_parser = subparsers.add_parser( @@ -1348,16 +1373,21 @@ def main(argv=None): type=int, help="Limit parallelism to N.", ) - foreach_parser.add_argument("--cwd", help="Working directory for the Codex session.") + foreach_parser.add_argument("--cwd", help="Working directory for the agent session.") + foreach_parser.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) foreach_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo and use --full-auto.", + help="Disable --yolo (Codex uses --full-auto).", ) foreach_parser.add_argument( "--flags", - help="Additional raw CLI flags to pass to Codex (quoted as needed).", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) create_parser = subparsers.add_parser( @@ -1440,6 +1470,7 @@ def main(argv=None): args.cwd, args.yolo, args.flags, + args.backend, ) if result.failed: raise SystemExit(1) @@ -1509,6 +1540,7 @@ def main(argv=None): args.cwd, args.yolo, args.flags, + args.backend, ) except TakeError as exc: print(str(exc), file=sys.stderr) @@ -1537,6 +1569,7 @@ def main(argv=None): args.cwd, args.yolo, args.flags, + args.backend, ) except TakeError as exc: raise SystemExit(str(exc)) from None @@ -1572,6 +1605,7 @@ def main(argv=None): yolo=args.yolo, thread_id=None, flags=args.flags, + backend=args.backend, ) result = task_runner(progress=not args.quiet) if not result.success: @@ -1605,6 +1639,7 @@ def main(argv=None): args.max_iterations, args.completion_promise, args.ralph_fresh, + args.backend, )() return if args.command == "science": @@ -1620,6 +1655,7 @@ def main(argv=None): args.completion_promise, args.ralph_fresh, max_duration_seconds, + args.backend, )() return if args.command == "lead": @@ -1629,7 +1665,15 @@ def main(argv=None): if args.no_leadbook and args.leadbook: raise SystemExit("--leadbook and --no-leadbook are mutually exclusive.") leadbook = False if args.no_leadbook else args.leadbook - lead(args.minutes, prompt, args.cwd, args.yolo, args.flags, leadbook) + lead( + args.minutes, + prompt, + args.cwd, + args.yolo, + args.flags, + leadbook, + args.backend, + ) except KeyboardInterrupt: raise SystemExit(130) except Exception as exc: @@ -1664,6 +1708,7 @@ def main(argv=None): args.yolo, args.flags, not args.quiet, + backend=args.backend, ) except TaskFailed as exc: exit_code = 1 @@ -1676,13 +1721,19 @@ def main(argv=None): args.thread_id, args.flags, include_thinking=args.include_thinking, + backend=args.backend, ) message = session(prompt) if args.print_thread_id: print(f"thread_id={session.thread_id}", file=sys.stderr) else: message = agent( - prompt, args.cwd, args.yolo, args.flags, args.include_thinking + prompt, + args.cwd, + args.yolo, + args.flags, + args.include_thinking, + args.backend, ) if message is not None: diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index 547c90d..c7cc6a0 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -41,6 +41,7 @@ def foreach( cwd=None, yolo=True, flags=None, + backend=None, ): """Run a task file over each item in list_file and update the file.""" lines, ends_with_newline = _read_lines(list_file) @@ -75,6 +76,7 @@ def foreach( cwd, yolo, flags, + backend, counts, results, progress, @@ -171,6 +173,7 @@ def _run_item( cwd, yolo, flags, + backend, counts, results, progress, @@ -195,6 +198,7 @@ def _run_item( yolo=yolo, thread_id=None, flags=flags, + backend=backend, ) max_iterations = task.max_iterations result = task() diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index de24b12..f7563cd 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -234,8 +234,9 @@ def __init__( yolo=True, thread_id=None, flags=None, + backend=None, ): - super().__init__(path, item_text, None, cwd, yolo, thread_id, flags) + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend) self.issue = issue self.project = project self._progress_updates = True @@ -308,6 +309,7 @@ def __init__( cwd=None, yolo=True, flags=None, + backend=None, ): task_map = _task_file_map(task_files) self.project = Project(project, name, has_label=list(task_map)) @@ -337,6 +339,7 @@ def __init__( yolo, None, flags, + backend, ) def __call__(self, progress=False): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 989d8d3..6311046 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -1,6 +1,6 @@ -"""Periodic lead loop for long-running Codex work. +"""Periodic lead loop for long-running agent work. -lead keeps a single Codex thread alive and periodically checks in with the +lead keeps a single agent thread alive and periodically checks in with the current time and a reminder of the original instructions. Each check-in expects a small JSON status payload so the loop can decide whether to continue. When a leadbook is enabled, its contents are injected into each check-in and must be @@ -68,16 +68,25 @@ """ -def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): +def lead( + minutes, + prompt, + cwd=None, + yolo=True, + flags=None, + leadbook=None, + backend=None, +): """Run a periodic lead loop. Args: minutes: Check-in interval in whole minutes (>= 0). prompt: The original instruction prompt. - cwd: Optional working directory for the Codex session. - yolo: Whether to pass --yolo to Codex. - flags: Additional raw CLI flags to pass to Codex. + cwd: Optional working directory for the agent session. + yolo: Whether to pass --yolo to the agent backend. + flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. + backend: Agent backend to use ("codex" or "cursor"). Returns: The last parsed JSON status object. @@ -90,7 +99,7 @@ def lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None): raise ValueError("prompt must be a non-empty string") interval = minutes * 60 - session = Agent(cwd, yolo, None, flags) + session = Agent(cwd, yolo, None, flags, backend=backend) pushover = Pushover() pushover.ensure_ready() title = _format_title(prompt) diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 879f28a..205e5af 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -1,4 +1,4 @@ -"""Ralph Wiggum-style loop for Codex runs.""" +"""Ralph Wiggum-style loop for agent runs.""" import os import re @@ -24,6 +24,7 @@ def __init__( max_iterations=0, completion_promise=None, fresh=True, + backend=None, ): if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -41,6 +42,7 @@ def __init__( self.max_iterations = max_iterations self.completion_promise = completion_promise self.fresh = fresh + self.backend = backend self.include_thinking = True def hook_before_loop(self): @@ -164,6 +166,7 @@ def __call__(self): self.flags, welfare=True, include_thinking=self.include_thinking, + backend=self.backend, ) elif runner is None: runner = Agent( @@ -173,6 +176,7 @@ def __call__(self): self.flags, welfare=True, include_thinking=self.include_thinking, + backend=self.backend, ) prompt = self.build_prompt(iteration) diff --git a/src/codexapi/science.py b/src/codexapi/science.py index 296377b..b976be8 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -102,6 +102,7 @@ def __init__( completion_promise=None, fresh=True, max_duration_seconds=0, + backend=None, ): if max_duration_seconds < 0: raise ValueError("max_duration_seconds must be >= 0") @@ -116,6 +117,7 @@ def __init__( max_iterations, completion_promise, fresh, + backend, ) self.include_thinking = True self._prompt_a = prompt_a @@ -129,6 +131,7 @@ def __init__( self._loop_started_monotonic = None self._duration_limit_hit = False self._last_iteration = 0 + self._backend = backend def hook_before_loop(self): super().hook_before_loop() @@ -196,7 +199,7 @@ def _append_logbook(self, iteration, message): def _extract_and_notify(self, message): prompt = _build_metrics_prompt(self._task, message, self._best_metrics) try: - output = agent(prompt, self.cwd, self.yolo, self.flags) + output = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) except Exception as exc: _warn(f"Metrics extraction failed: {exc}") return @@ -219,7 +222,7 @@ def _build_run_title(self): ] ) try: - title = agent(prompt, self.cwd, self.yolo, self.flags) + title = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) except Exception: title = "" title = _single_line(title).strip() diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 1a1697f..d54d731 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -1,4 +1,4 @@ -"""Task wrapper for running Codex Agent flows with checkers.""" +"""Task wrapper for running agent flows with checkers.""" import json import logging @@ -183,14 +183,23 @@ def _format_task_title(prompt): return title -def estimate(prompt, agent_output, check_output, cwd, yolo, flags, previous_total): +def estimate( + prompt, + agent_output, + check_output, + cwd, + yolo, + flags, + previous_total, + backend=None, +): estimate_prompt = _build_estimate_prompt( prompt, agent_output or "", check_output or "", previous_total, ) - output = agent(estimate_prompt, cwd, yolo, flags) + output = agent(estimate_prompt, cwd, yolo, flags, backend=backend) return _estimate_result(output) @@ -248,6 +257,7 @@ def task( tear_down=None, on_success=None, on_failure=None, + backend=None, ): """Run a prompt with optional checker-driven retries. @@ -256,14 +266,15 @@ def task( check: False to skip verification, None for the default check, or a string check prompt. The string "None" skips verification. max_iterations: Maximum number of task iterations (0 means unlimited). - cwd: Optional working directory for the Codex session. - yolo: Whether to pass --yolo to Codex. - flags: Additional raw CLI flags to pass to Codex. + cwd: Optional working directory for the agent session. + yolo: Whether to pass --yolo to the agent backend. + flags: Additional raw CLI flags to pass to the agent backend. progress: Whether to show a tqdm progress bar with status updates. set_up: Optional setup prompt to run before the task. tear_down: Optional cleanup prompt to run after the task. on_success: Optional prompt to run after a successful task. on_failure: Optional prompt to run after a failed task. + backend: Agent backend to use ("codex" or "cursor"). Returns: The agent's response text when the task succeeds. @@ -283,6 +294,7 @@ def task( tear_down, on_success, on_failure, + backend, ) if result.success: return result.summary @@ -301,6 +313,7 @@ def task_result( tear_down=None, on_success=None, on_failure=None, + backend=None, ): """Run a prompt with optional checker-driven retries and return TaskResult. @@ -330,6 +343,7 @@ def task_result( tear_down=tear_down_text, on_success=on_success_text, on_failure=on_failure_text, + backend=backend, ) return runner(progress=progress) @@ -357,7 +371,7 @@ def __repr__(self): class Task: - """ Run a Codex Agent in a directory until it is verifiably done. + """ Run an agent in a directory until it is verifiably done. Subclass and override these functions: set_up : prepare working directory, install things etc. tear_down : undo the above and leave machine in a clean state @@ -375,6 +389,7 @@ def __init__( yolo=True, thread_id=None, flags=None, + backend=None, ): if max_iterations < 0: raise ValueError("max_iterations must be >= 0") @@ -387,6 +402,7 @@ def __init__( self.check_text = None self._yolo = yolo self._flags = flags + self._backend = backend self._progress_enabled = False self._progress_updates = False self._progress_bar = None @@ -399,6 +415,7 @@ def __init__( thread_id, flags, welfare=True, + backend=backend, ) def set_up(self): @@ -422,7 +439,13 @@ def check(self, output=None): last_output = output if output is not None else self.last_output last_output = last_output or "" check_prompt = _build_check_prompt(check_text, last_output) - check_output = agent(check_prompt, self.cwd, self._yolo, self._flags) + check_output = agent( + check_prompt, + self.cwd, + self._yolo, + self._flags, + backend=self._backend, + ) self.last_check_output = check_output success, reason = _check_result(check_output) if success: @@ -498,6 +521,7 @@ def _estimate_progress(self, agent_output, check_output): self._yolo, self._flags, self._progress_total, + backend=self._backend, ), None, ) @@ -671,12 +695,21 @@ def __init__( tear_down=None, on_success=None, on_failure=None, + backend=None, ): if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") if max_iterations < 0: raise ValueError("max_iterations must be >= 0") - super().__init__(prompt, max_iterations, cwd, yolo, thread_id, flags) + super().__init__( + prompt, + max_iterations, + cwd, + yolo, + thread_id, + flags, + backend, + ) self.check_text = check self._set_up = _validate_hook("set_up", set_up) self._tear_down = _validate_hook("tear_down", tear_down) @@ -685,7 +718,7 @@ def __init__( def _run_hook(self, text): if text: - agent(text, self.cwd, self._yolo, self._flags) + agent(text, self.cwd, self._yolo, self._flags, backend=self._backend) def set_up(self): self._run_hook(self._set_up) @@ -698,3 +731,4 @@ def on_success(self, result): def on_failure(self, result): self._run_hook(self._on_failure) + diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index 9b4ca8e..e9e606b 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -77,6 +77,7 @@ def __init__( yolo=True, thread_id=None, flags=None, + backend=None, ): task_def = load_task_file(path) if max_iterations is None: @@ -106,6 +107,7 @@ def __init__( tear_down=rendered["tear_down"], on_success=rendered["on_success"], on_failure=rendered["on_failure"], + backend=backend, ) return super().__init__( @@ -120,4 +122,5 @@ def __init__( tear_down=rendered["tear_down"], on_success=rendered["on_success"], on_failure=rendered["on_failure"], + backend=backend, ) diff --git a/tests/test_science.py b/tests/test_science.py index 538e246..1125418 100644 --- a/tests/test_science.py +++ b/tests/test_science.py @@ -44,6 +44,7 @@ def __init__( flags=None, welfare=False, include_thinking=False, + backend=None, ): pass diff --git a/tests/test_task_progress.py b/tests/test_task_progress.py index b020deb..dae8e1c 100644 --- a/tests/test_task_progress.py +++ b/tests/test_task_progress.py @@ -1,7 +1,7 @@ import sys import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) @@ -50,7 +50,10 @@ def notify_pushover(self, result): class TaskProgressEstimateFailureTests(unittest.TestCase): def test_progress_does_not_crash_when_initial_estimate_fails(self): task = _ImmediateSuccessTask() - with patch("codexapi.task.estimate", side_effect=RuntimeError("bad json")): + mock_estimate = Mock(side_effect=RuntimeError("bad json")) + with patch.dict( + Task._estimate_progress.__globals__, {"estimate": mock_estimate} + ): result = task(progress=True) self.assertTrue(result.success) self.assertEqual(result.iterations, 1) @@ -59,9 +62,11 @@ def test_progress_does_not_crash_when_initial_estimate_fails(self): def test_progress_does_not_crash_when_later_estimate_fails(self): task = _ImmediateSuccessTask() - with patch( - "codexapi.task.estimate", - side_effect=[(5, "initial"), RuntimeError("bad json")], + mock_estimate = Mock( + side_effect=[(5, "initial"), RuntimeError("bad json")] + ) + with patch.dict( + Task._estimate_progress.__globals__, {"estimate": mock_estimate} ): result = task(progress=True) self.assertTrue(result.success) From f9e2e3a66ed799aed0f037052ed3adb7ea8b50d5 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 3 Mar 2026 18:30:22 +0100 Subject: [PATCH 47/78] Bump version to 0.8.0 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9be2bd5..e82a901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.7.3" +version = "0.8.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index d3f12d7..17c694a 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.7.3" +__version__ = "0.8.0" From 209d2dadd238e5825b7a1a78789887726d2f70a4 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 01:32:02 +0100 Subject: [PATCH 48/78] Add codexapi agent v1 design spec --- docs/agent-v1.md | 758 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 docs/agent-v1.md diff --git a/docs/agent-v1.md b/docs/agent-v1.md new file mode 100644 index 0000000..16026ce --- /dev/null +++ b/docs/agent-v1.md @@ -0,0 +1,758 @@ +# codexapi agent V1 + +## Purpose + +`codexapi agent` is a long-term fire-and-forget orchestration layer built on top +of the existing agent, task, science, and lead primitives. + +The V1 goal is not to invent a new kind of coding agent. The goal is to make a +durable agent that can: + +- keep working for days +- survive sleep, reboot, and missed scheduler runs +- be inspected and controlled from the CLI +- accept messages while it is running +- delegate coding work to `codexapi task` or `codexapi science` +- escalate to the user when needed + +The design is intentionally simple. It uses durable filesystem state plus one +periodic scheduler entry per `CODEXAPI_HOME`. + +## Non-Goals + +V1 does not try to solve everything. + +- No daemon is required. +- No SSH is required. +- No cross-host migration or "teleportation" of running agents. +- No separate task-agent and watcher-agent runtimes. +- No catch-up replay of missed heartbeat ticks. +- No dependence on real cron in automated tests. +- No shared append-only logs written by multiple hosts. + +These are deliberate omissions. They keep the system small, portable, and easy +to reason about. + +## Top-Level Model + +An agent is a durable record plus a periodic wake mechanism. + +- There is one agent type. +- Each agent has a `stop_policy`. +- Each agent belongs to exactly one `CODEXAPI_HOME`. +- Each agent is owned by exactly one hostname. +- Only the owning hostname may wake and run the agent. +- Any host that can see the shared filesystem may inspect the agent and queue + commands for it. + +The agent's durable truth is the state stored under `CODEXAPI_HOME`, not a live +backend process. Each wake starts a fresh backend process and resumes from the +saved thread id when available. + +## `CODEXAPI_HOME` + +`CODEXAPI_HOME` is the root of a complete agent control plane. + +Default: + +```text +~/.codexapi +``` + +Override: + +```text +CODEXAPI_HOME=/path/to/home +``` + +Why this exists: + +- It isolates live state from tests. +- It allows multiple independent codexapi installations on one machine. +- It allows a shared filesystem setup without forcing all state into one global + namespace. + +Two different `CODEXAPI_HOME` values are two different systems. They do not see +each other's agents, locks, scheduler wrappers, or cron entries. + +## Agent Model + +Each agent stores at least: + +- `id`: stable identifier +- `name`: human-readable unique name within the home +- `created_at`: UTC timestamp +- `created_by`: user name or parent agent name +- `hostname`: owning host for execution +- `cwd`: working directory +- `prompt`: original instruction text +- `stop_policy`: `until_done` or `until_stopped` +- `status`: current lifecycle state +- `thread_id`: backend resume id, or empty +- `heartbeat_minutes`: heartbeat interval +- `last_wake_at`: last attempted wake time in UTC +- `last_success_at`: last completed wake time in UTC +- `next_wake_at`: next heartbeat due time in UTC +- `wake_requested_at`: durable "run soon" flag for queued commands/messages +- `unread_message_count`: messages not yet folded into a wake +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` +- `child_ids` +- `last_error`: most recent failure summary, if any +- `activity`: short status text for `agent list` + +V1 uses one agent type with one explicit lifecycle hint: + +- `stop_policy=until_done`: agent is expected to decide when it is finished +- `stop_policy=until_stopped`: agent is expected to keep running until stopped + +This keeps the runtime unified while preserving a small but important semantic +difference for scheduling and UI. + +## Lifecycle States + +V1 keeps the state model small: + +- `ready`: can be woken when due +- `running`: a wake is currently in progress +- `paused`: do not wake until resumed +- `done`: completed by the agent's own judgment +- `canceled`: stopped by an explicit command +- `error`: last wake failed and the agent needs attention or another wake + +Why these states: + +- `ready` and `running` are enough for normal operation +- `paused`, `done`, and `canceled` are user-visible terminal or semi-terminal + control states +- `error` makes failures explicit without inventing a richer failure taxonomy + +## Filesystem Layout + +All paths below are relative to `CODEXAPI_HOME`. + +```text +agents/ + / + meta.json + state.json + AGENTBOOK.md + commands/ + new/ + claimed/ + hosts/ + / + session.json + run.lock + runs/ +locks/ + .tick..lock +bin/ + agent-tick +cron/ + agent.cron +``` + +### `agents//meta.json` + +Purpose: +- Stable identity and configuration. + +Writer: +- Owner host only after agent creation, except for explicit configuration + changes. + +Readers: +- Any host. + +Format: +- JSON object. + +Why it exists: +- Separates mostly-static configuration from rapidly changing state. + +Suggested contents: +- `id`, `name`, `created_at`, `created_by`, `hostname`, `cwd`, `prompt`, + `stop_policy`, `heartbeat_minutes` + +### `agents//state.json` + +Purpose: +- Current snapshot for CLI inspection. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- JSON object rewritten atomically with temp file + rename. + +Why it exists: +- `agent list` and `agent show` should not need to reconstruct state from many + files or logs. + +Suggested contents: +- `status`, `thread_id`, `last_wake_at`, `last_success_at`, `next_wake_at`, + `wake_requested_at`, `unread_message_count`, token totals, `activity`, + `last_error`, `child_ids` + +### `agents//AGENTBOOK.md` + +Purpose: +- Human-readable working memory for the agent, similar to the leadbook. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- Markdown. + +Why it exists: +- Thread ids are not sufficient durable memory. The book is the portable, + inspectable memory surface. + +### `agents//commands/new/` + +Purpose: +- Durable cross-host command spool. + +Writer: +- Any host may create new files here. + +Readers: +- Owner host only for processing, any host for debugging. + +Format: +- One JSON file per command. + +Why it exists: +- It avoids shared append logs and avoids requiring SSH or direct host + reachability. + +Filename rule: + +```text +....json +``` + +Writers must: + +- write to a temp file in the same directory tree +- `fsync` if practical +- rename atomically into `commands/new/` + +Supported V1 commands: + +- `send` +- `wake` +- `pause` +- `resume` +- `cancel` + +### `agents//commands/claimed/` + +Purpose: +- Temporary processing area for commands taken by the owner host. + +Writer: +- Owner host only. + +Readers: +- Mainly owner host; other hosts may inspect for debugging. + +Format: +- Same JSON command files, moved from `new/`. + +Why it exists: +- Claim-by-rename is simple, durable, and avoids double processing. + +After a claimed command is applied, the owner host should record the outcome in +`state.json` or a run record and then remove the command file. The command file +is transport, not long-term audit storage. + +### `agents//hosts//session.json` + +Purpose: +- Host-local runtime data for the owner host. + +Writer: +- Owner host only. + +Readers: +- Mostly owner host. + +Format: +- JSON object. + +Why it exists: +- Keeps the liveliest mutable runtime fields under a host-specific path. + +Suggested contents: +- `thread_id` +- environment snapshot used for execution +- last run metadata that does not need to be duplicated in `state.json` + +### `agents//hosts//run.lock` + +Purpose: +- Non-blocking per-agent run lock. + +Writer: +- Owner host only. + +Readers: +- Owner host only in normal operation. + +Format: +- Permanent lock file used with `flock` or `fcntl`. + +Why it exists: +- Prevents two entry points from resuming the same backend thread at the same + time. + +### `agents//hosts//runs/` + +Purpose: +- Per-wake run records for debugging and recovery. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- One JSON file per wake. + +Why it exists: +- Per-run files are easier to inspect and safer than multi-host append logs. + +Suggested contents: +- start and end times +- reason for wake +- commands consumed +- agent reply text or status payload intended for the CLI +- token deltas +- result summary +- error details if any + +### `bin/agent-tick` + +Purpose: +- Stable wrapper script for cron. + +Writer: +- `codexapi agent install-cron` + +Readers: +- Cron and the user. + +Format: +- Executable shell script. + +Why it exists: +- Cron has a sparse environment. The wrapper pins the interpreter and exports a + safe environment. + +The wrapper should: + +- export the resolved `CODEXAPI_HOME` +- set a safe `PATH` +- invoke the exact Python interpreter or installed `codexapi` path discovered + at install time + +### `cron/agent.cron` + +Purpose: +- Record of the cron line managed for this `CODEXAPI_HOME`. + +Writer: +- `codexapi agent install-cron` + +Readers: +- User and installer commands. + +Format: +- Plain text. + +Why it exists: +- Makes scheduler installation inspectable and testable without reading the + user's entire crontab. + +## Ownership Rules + +The design is intentionally asymmetric. + +- Any host may read any agent in the same `CODEXAPI_HOME`. +- Only the owner host may run the agent. +- Any host may enqueue command files in `commands/new/`. +- Only the owner host may mutate `state.json`, `AGENTBOOK.md`, host runtime + files, and run records. + +Why this matters: + +- It keeps cross-host writes minimal. +- It avoids shared append logs. +- It allows one shared registry across machines without letting an agent wake on + the wrong host. + +## Scheduler + +V1 uses exactly one cron entry per `CODEXAPI_HOME` and per host. + +Cron cadence: + +- every minute + +Cron target: + +- `CODEXAPI_HOME/bin/agent-tick` + +Why one scheduler entry: + +- one place to reason about wake behavior +- no per-agent cron management +- easy recovery after reboot or sleep + +Why cron: + +- available on macOS and Linux +- no root requirement +- simple installation story + +## Tick Lock + +Each host uses a host-specific scheduler lock: + +```text +locks/.tick..lock +``` + +Locking rules: + +- lock acquisition is non-blocking +- if the lock is held, `codexapi agent tick` exits `0` immediately +- missed scheduler invocations are dropped, not queued + +The lock file itself may contain debug text such as pid and start time, but the +authority is the kernel file lock, not file existence. + +Why this matters: + +- a long tick must not cause future ticks to pile up +- crash recovery is automatic because kernel locks are released when the process + dies + +## Per-Agent Run Lock + +Each agent has its own non-blocking run lock under its owner host directory. + +Rules: + +- `tick`, `send`, and any future explicit wake path must all respect this lock +- if the lock is held, the caller must not wait +- if new commands arrive while the agent is running, they stay queued for the + next wake + +Why this matters: + +- one backend process per agent +- no concurrent `resume` on the same thread id + +## Tick Semantics + +`codexapi agent tick` should: + +1. resolve `CODEXAPI_HOME` +2. resolve the current hostname +3. take the host-specific tick lock or exit `0` +4. scan all agents in this home +5. ignore agents whose owner hostname does not match +6. select agents that are due +7. try each due agent with its non-blocking run lock + +An agent is due when all of the following are true: + +- `status` is `ready` or `error` +- owner hostname matches the current hostname +- one of: + - `wake_requested_at` is set + - unread commands/messages exist + - `next_wake_at` is present and in the past + +Heartbeat behavior: + +- missed heartbeat opportunities are dropped +- there is no replay of missed intervals after sleep or reboot +- the next heartbeat is scheduled from the time the current wake finishes, not + from the last planned heartbeat slot + +Why this matters: + +- heartbeats are a chance to check in, not a durable queue +- durable user intent must live in command files, not in hypothetical missed + ticks + +## Command Processing + +Command files are the durable cross-host control plane. + +Suggested command shape: + +```json +{ + "id": "20260306T211500Z.host.pid.abcd", + "created_at": "2026-03-06T21:15:00Z", + "origin_hostname": "workstation-a", + "kind": "send", + "body": "Status?", + "author": "mark" +} +``` + +Processing rules: + +- owner host claims commands by rename from `new/` to `claimed/` +- commands are applied in timestamp order +- `pause` and `cancel` are applied before starting a new backend wake +- `send` contributes to the next prompt and increments unread counts until + consumed +- `wake` means run soon even if no heartbeat is due +- `resume` only changes state when the agent is paused +- after successful application, the owner host records the result in state or a + run record and deletes the claimed file + +Why command files instead of SSH: + +- durable when the owner host is asleep or unreachable +- portable +- fewer assumptions about local network setup + +## Wakes and Backend Process Model + +Each wake is a fresh backend process. + +Rules: + +- do not keep a `codex` process alive between heartbeats +- when a wake starts, resume from `thread_id` if present +- when the wake ends, persist the updated `thread_id` +- if no `thread_id` exists, start a fresh thread + +Why this matters: + +- robust to reboot and crash +- simpler process management +- clearer token accounting per wake + +The backend thread id is useful memory, but not the source of truth. Durable +memory lives in the agent home, especially `state.json`, command files, and +`AGENTBOOK.md`. + +## Environment Handling + +The scheduler environment and the agent execution environment are not assumed to +be the same. + +Each agent should persist enough environment to resume sanely: + +- `cwd` +- `PATH` +- `VIRTUAL_ENV`, if set +- interpreter path used to launch codexapi-related subprocesses when relevant + +Why this matters: + +- the cron-driven scheduler may run from a different venv than the one the user + had active when the agent was created +- repo commands like `python`, `pytest`, and tool wrappers often depend on + `PATH` and `VIRTUAL_ENV` + +V1 should store only the minimum needed to recreate the expected environment. + +## Token Accounting + +V1 should not pretend to know dollar cost. + +Track: + +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` + +Token totals belong in `state.json` so `agent list` can show them cheaply. + +Why this matters: + +- heartbeat-heavy agents can become unexpectedly expensive in quota terms +- users need a simple proxy for long-running agent cost + +`avg_tokens_per_hour` is a lifetime running average in V1. More detailed recent +windows can be added later if needed. + +## CLI Contract + +V1 CLI surface: + +- `codexapi agent start` +- `codexapi agent list` +- `codexapi agent read` +- `codexapi agent show` +- `codexapi agent send` +- `codexapi agent wake` +- `codexapi agent pause` +- `codexapi agent resume` +- `codexapi agent cancel` +- `codexapi agent tick` +- `codexapi agent install-cron` + +Expected behavior: + +- `start` creates the agent directory, meta/state files, and host runtime files +- `list` reads only this `CODEXAPI_HOME` +- `read` shows recent user-visible communication derived from state and run + records +- `show` reads one agent's current snapshot and recent run history +- `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files +- `tick` processes due agents for the current hostname only +- `install-cron` installs exactly one scheduler entry for this home on this host + +Why command-oriented CLI actions: + +- one path for local and cross-host control +- durable intent +- simpler concurrency model + +## Failure Recovery + +V1 should explicitly recover from common failure modes. + +### Reboot or Sleep + +- missed cron minutes are ignored +- the next cron minute runs `agent tick` +- due agents are selected from current state, not from queued heartbeat ticks + +### Tick Crash + +- kernel lock is released when the tick process dies +- next cron minute may run normally + +### Wake Crash + +- per-agent run lock is released when the process dies +- next tick sees the agent is not actually locked +- if `state.json` still says `running`, reconcile it to `error` or `ready` + before proceeding + +### Owner Host Unavailable + +- other hosts may still inspect the agent and enqueue commands +- commands remain durable until the owner host comes back + +## Testing Strategy + +The main automated testing tool is a temporary `CODEXAPI_HOME`. + +Why this is the right testing seam: + +- it isolates tests from live agents +- it allows end-to-end command and tick tests without real cron +- it matches the real control-plane boundary + +### Test Rules + +- every integration test sets `CODEXAPI_HOME` to a temp directory +- tests call CLI commands or internal functions directly +- tests run `codexapi agent tick` directly instead of invoking cron +- tests must never depend on the default `~/.codexapi` + +### Test Layers + +Unit tests: + +- due-agent selection +- heartbeat scheduling +- token accounting +- path resolution +- command parsing +- state transition logic + +Filesystem integration tests: + +- create an agent +- enqueue command files +- run `tick` +- verify command consumption, state updates, and next wake times +- verify that different `CODEXAPI_HOME` roots are fully isolated + +Backend-stub integration tests: + +- replace real backend execution with a fake runner +- return canned outputs and thread ids +- verify prompt construction, session resume, and token accounting + +Scheduler tests: + +- verify wrapper script generation +- verify cron line rendering +- verify that two different `CODEXAPI_HOME` roots on one host produce separate + scheduler artifacts +- do not touch a real user crontab in normal automated tests + +Cross-host tests: + +- fake different hostnames +- verify that only the owner hostname wakes an agent +- verify that non-owner hosts can still enqueue commands + +Locking tests: + +- simulate tick lock contention and assert fast `0` exit +- simulate per-agent run lock contention and assert no second wake starts + +## Invariants + +These are the rules the implementation should preserve. + +- `CODEXAPI_HOME` is a complete isolated control plane. +- One cron installation belongs to one home on one host. +- Only the owner hostname may wake an agent. +- Heartbeat opportunities are lossy. +- Commands are durable. +- No caller waits on the tick lock or per-agent run lock. +- There is never more than one live backend process per agent. +- The backend thread id is useful state, not the source of truth. +- Cross-host writes use one-file command spooling, not shared append logs. +- Agent state shown in the CLI comes from `state.json`, not from expensive live + reconstruction. + +## Why This Design Is Small Enough + +This design deliberately avoids many attractive additions. + +- It does not require a daemon. +- It does not require host-to-host RPC. +- It does not require richer distributed locking than the filesystem already + provides. +- It does not require a database. + +What remains is the minimum necessary structure for a durable, inspectable, +multi-day agent system: + +- one state root +- one scheduler entry +- one agent directory per agent +- one command spool per agent +- one host owner per agent +- one run at a time + +That is a good V1 shape. From 05ea545c37732560cb20c0030aa9125e16e2ff1a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 01:37:38 +0100 Subject: [PATCH 49/78] Add durable agent control-plane foundation --- src/codexapi/agent.py | 35 +- src/codexapi/agents.py | 864 +++++++++++++++++++++++++++++++++++++++++ src/codexapi/cli.py | 178 +++++++++ tests/test_agents.py | 131 +++++++ 4 files changed, 1202 insertions(+), 6 deletions(-) create mode 100644 src/codexapi/agents.py create mode 100644 tests/test_agents.py diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 1e2526c..6ab2f11 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -31,6 +31,7 @@ def agent( flags=None, include_thinking=False, backend=None, + env=None, ): """Run a single agent turn and return only the agent's message. @@ -41,12 +42,13 @@ def agent( flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend + prompt, cwd, None, yolo, flags, include_thinking, backend, env ) return message @@ -78,6 +80,7 @@ def __init__( welfare=False, include_thinking=False, backend=None, + env=None, ): """Create a new session wrapper. @@ -90,6 +93,7 @@ def __init__( and raise WelfareStop if the agent outputs MAKE IT STOP. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. """ self.cwd = cwd self._yolo = yolo @@ -98,6 +102,7 @@ def __init__( self._include_thinking = include_thinking self.thread_id = thread_id self._backend = backend + self._env = env def __call__(self, prompt): """Send a prompt to the agent backend and return the message.""" @@ -111,6 +116,7 @@ def __call__(self, prompt): self._flags, self._include_thinking, self._backend, + self._env, ) if thread_id: self.thread_id = thread_id @@ -119,14 +125,14 @@ def __call__(self, prompt): return message -def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend): +def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): backend = _resolve_backend(backend) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking) - return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking) + return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -155,6 +161,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -166,7 +173,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): return _parse_jsonl(result.stdout, include_thinking) -def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" command = [ _CURSOR_BIN, @@ -189,6 +196,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -285,3 +293,18 @@ def _parse_cursor_json(output, include_thinking): if not isinstance(session_id, str): session_id = None return result, session_id + + +def _merged_env(env): + """Return subprocess env overlaying the current process env.""" + if env is None: + return None + if not isinstance(env, dict): + raise TypeError("env must be a dict or None") + merged = os.environ.copy() + for key, value in env.items(): + if value is None: + merged.pop(str(key), None) + else: + merged[str(key)] = str(value) + return merged diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py new file mode 100644 index 0000000..4092958 --- /dev/null +++ b/src/codexapi/agents.py @@ -0,0 +1,864 @@ +"""Durable long-running agent control plane.""" + +import json +import os +import random +import socket +import string +import tempfile +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import fcntl + +from .agent import Agent +from .pushover import Pushover + +_DEFAULT_HOME = "~/.codexapi" +_AGENTBOOK_TEMPLATE = """# Agentbook + +Use this file as the durable working memory for the agent. +Append dated notes as work progresses. +Keep entries short and concrete. +""" +_AGENT_PROMPT = ( + "You are a long-term codexapi agent. You are being woken up to make progress " + "on an ongoing job. Be independent and practical. Manage work and follow " + "through. Use codexapi task or codexapi science when you want a separate " + "coding worker. If you need the user's attention, put a short message in the " + "reply field. If something is urgent and should send Pushover, put it in the " + "notify field. Respond with JSON only." +) +_AGENT_JSON = ( + "Respond with JSON only (no markdown/backticks/extra text).\n" + "Return a single JSON object with keys:\n" + " status: string (one line)\n" + " continue: boolean\n" + " reply: string (optional)\n" + " notify: string (optional)\n" +) +_COMMAND_KINDS = {"send", "wake", "pause", "resume", "cancel"} +_STOP_POLICIES = {"until_done", "until_stopped"} +_TERMINAL_STATES = {"done", "canceled"} +_ACTIVE_STATES = {"ready", "error", "running", "paused"} + + +def codexapi_home(): + """Return the resolved codexapi home path.""" + value = os.environ.get("CODEXAPI_HOME", _DEFAULT_HOME) + return Path(value).expanduser().resolve() + + +def current_hostname(): + """Return the current hostname.""" + name = socket.gethostname().strip() + return name or "unknown-host" + + +def utc_now(): + """Return the current UTC time.""" + return datetime.now(timezone.utc) + + +def format_utc(value): + """Format a UTC datetime as an ISO string with Z.""" + if value is None: + return "" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + value = value.astimezone(timezone.utc).replace(microsecond=0) + return value.isoformat().replace("+00:00", "Z") + + +def parse_utc(value): + """Parse a UTC timestamp written by this module.""" + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def start_agent( + prompt, + cwd=None, + name=None, + created_by=None, + stop_policy="until_done", + heartbeat_minutes=5, + backend=None, + yolo=True, + flags=None, + home=None, + hostname=None, + now=None, +): + """Create a durable agent and return its current snapshot.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + if stop_policy not in _STOP_POLICIES: + raise ValueError("stop_policy must be until_done or until_stopped") + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + _ensure_home(home) + + agent_id = uuid.uuid4().hex + agent_dir = _agent_dir(home, agent_id) + commands_new = agent_dir / "commands" / "new" + commands_claimed = agent_dir / "commands" / "claimed" + host_dir = agent_dir / "hosts" / host + runs_dir = host_dir / "runs" + + commands_new.mkdir(parents=True, exist_ok=False) + commands_claimed.mkdir(parents=True, exist_ok=False) + runs_dir.mkdir(parents=True, exist_ok=False) + + if created_by is None: + created_by = os.environ.get("USER") or "user" + cwd = _resolve_cwd(cwd) + session = { + "thread_id": "", + "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), + "yolo": bool(yolo), + "flags": flags or "", + "cwd": cwd, + "env": _capture_env(), + "pending_messages": [], + } + agent_name = _choose_name(home, prompt, name) + meta = { + "id": agent_id, + "name": agent_name, + "created_at": format_utc(now), + "created_by": str(created_by), + "hostname": host, + "cwd": cwd, + "prompt": prompt.strip(), + "stop_policy": stop_policy, + "heartbeat_minutes": int(heartbeat_minutes), + } + state = { + "id": agent_id, + "name": agent_name, + "hostname": host, + "status": "ready", + "thread_id": "", + "last_wake_at": "", + "last_success_at": "", + "next_wake_at": format_utc(now), + "wake_requested_at": format_utc(now), + "unread_message_count": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "avg_tokens_per_hour": 0.0, + "child_ids": [], + "last_error": "", + "activity": "Created", + "reply": "", + } + + _write_json(agent_dir / "meta.json", meta) + _write_json(agent_dir / "state.json", state) + _write_json(host_dir / "session.json", session) + _write_text(agent_dir / "AGENTBOOK.md", _AGENTBOOK_TEMPLATE) + return _snapshot(agent_dir) + + +def list_agents(home=None): + """Return all agents in this CODEXAPI_HOME.""" + home = _resolve_home(home) + root = home / "agents" + if not root.exists(): + return [] + agents = [] + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + agents.append(_snapshot(agent_dir)) + except FileNotFoundError: + continue + agents.sort(key=lambda item: item["created_at"], reverse=True) + return agents + + +def show_agent(agent_ref, home=None): + """Return a full agent snapshot.""" + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir) + snapshot["meta"] = _read_json(agent_dir / "meta.json") + snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["session"] = _read_session(agent_dir) + snapshot["recent_runs"] = _recent_runs(agent_dir, 5) + return snapshot + + +def read_agent(agent_ref, limit=10, home=None): + """Return recent user-visible communication for an agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + items = [] + for run in _recent_runs(agent_dir, limit): + reply = run.get("reply") or "" + if reply: + items.append( + { + "kind": "agent", + "timestamp": run.get("ended_at") or run.get("started_at") or "", + "text": reply, + } + ) + for pending in session.get("pending_messages") or []: + text = pending.get("text") or "" + if text: + items.append( + { + "kind": "pending", + "timestamp": pending.get("created_at") or "", + "text": text, + } + ) + items.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "items": items[:limit], + } + + +def send_agent(agent_ref, message, author=None, home=None, hostname=None, now=None): + """Queue a message for an agent.""" + if not isinstance(message, str) or not message.strip(): + raise ValueError("message must be a non-empty string") + return _queue_command( + agent_ref, + "send", + message.strip(), + author, + home, + hostname, + now, + ) + + +def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=None): + """Queue a control command for an agent.""" + if kind not in _COMMAND_KINDS - {"send"}: + raise ValueError(f"Unsupported control command: {kind}") + return _queue_command(agent_ref, kind, "", author, home, hostname, now) + + +def tick(home=None, hostname=None, now=None, runner=None): + """Process due agents for the current host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + _ensure_home(home) + tick_lock = _tick_lock_path(home, host) + with _try_lock(tick_lock) as handle: + if handle is None: + return {"ran": False, "hostname": host, "processed": 0, "woken": 0} + _write_lock_info(handle, host, now) + processed = 0 + woken = 0 + for agent in list_agents(home): + if agent["hostname"] != host: + continue + outcome = _tick_agent(_agent_dir(home, agent["id"]), now, runner) + if outcome["processed"]: + processed += 1 + if outcome["woken"]: + woken += 1 + return {"ran": True, "hostname": host, "processed": processed, "woken": woken} + + +def resolve_agent_dir(agent_ref, home=None): + """Resolve an agent by id, unique id prefix, or name.""" + if not isinstance(agent_ref, str) or not agent_ref.strip(): + raise ValueError("agent reference is required") + home = _resolve_home(home) + ref = agent_ref.strip() + matches = [] + for item in list_agents(home): + if item["id"] == ref: + return _agent_dir(home, item["id"]) + if item["name"] == ref: + matches.append(item["id"]) + continue + if item["id"].startswith(ref): + matches.append(item["id"]) + matches = sorted(set(matches)) + if not matches: + raise ValueError(f"Unknown agent: {ref}") + if len(matches) > 1: + raise ValueError(f"Ambiguous agent reference: {ref}") + return _agent_dir(home, matches[0]) + + +def _tick_agent(agent_dir, now, runner): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + host_dir = agent_dir / "hosts" / meta["hostname"] + session_path = host_dir / "session.json" + session = _read_json(session_path) + run_lock_path = host_dir / "run.lock" + + with _try_lock(run_lock_path) as handle: + if handle is None: + return {"processed": False, "woken": False} + _write_lock_info(handle, meta["hostname"], now) + changed = False + if state.get("status") == "running": + state["status"] = "error" + state["last_error"] = "Previous wake did not exit cleanly." + state["activity"] = state["last_error"] + changed = True + commands = _claim_commands(agent_dir) + applied = _apply_commands(meta, state, session, commands, now) + if applied: + changed = True + if changed: + _sync_state_from_session(state, session) + _write_json(session_path, session) + _write_json(agent_dir / "state.json", state) + if state.get("status") not in ("ready", "error"): + return {"processed": bool(commands), "woken": False} + if not _is_due(state, now): + return {"processed": bool(commands), "woken": False} + _wake_agent(agent_dir, meta, state, session, now, commands, runner) + return {"processed": True, "woken": True} + + +def _wake_agent(agent_dir, meta, state, session, now, commands, runner): + prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) + state["status"] = "running" + state["last_wake_at"] = format_utc(now) + state["wake_requested_at"] = "" + state["activity"] = "Running" + _sync_state_from_session(state, session) + _write_json(agent_dir / "state.json", state) + + run = { + "id": _run_id(now), + "started_at": format_utc(now), + "ended_at": "", + "wake_reason": _wake_reason(state, commands), + "commands": [command["kind"] for command in commands], + "status": "", + "reply": "", + "notify": "", + "error": "", + "continue": True, + } + try: + outcome = _run_agent_turn(meta, session, prompt, runner) + response = _parse_agent_response(outcome["message"]) + ended = utc_now() + session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" + session["pending_messages"] = [] + state["reply"] = response["reply"] + state["last_success_at"] = format_utc(ended) + state["last_error"] = "" + state["thread_id"] = session["thread_id"] + state["wake_requested_at"] = "" + state["activity"] = response["status"] + if response["continue"]: + state["status"] = "ready" + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + else: + state["status"] = "done" + state["next_wake_at"] = "" + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["status"] = response["status"] + run["reply"] = response["reply"] + run["notify"] = response["notify"] + run["continue"] = bool(response["continue"]) + _write_run(agent_dir, meta["hostname"], run) + if response["notify"]: + title = f"Agent: {meta['name']}" + Pushover().send(title, response["notify"]) + except Exception as exc: + ended = utc_now() + state["status"] = "error" + state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__ + state["activity"] = state["last_error"] + state["wake_requested_at"] = "" + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["error"] = state["last_error"] + _write_run(agent_dir, meta["hostname"], run) + + +def _run_agent_turn(meta, session, prompt, runner=None): + if runner is not None: + return runner(meta, session, prompt) + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=session.get("env") or None, + ) + message = worker(prompt) + return {"message": message, "thread_id": worker.thread_id or ""} + + +def _parse_agent_response(output): + text = _strip_fence(str(output or "").strip()) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON response: {exc}") from None + if not isinstance(payload, dict): + raise ValueError("Agent response must be a JSON object.") + status = payload.get("status") + cont = payload.get("continue") + reply = payload.get("reply") + notify = payload.get("notify") + if not isinstance(status, str) or not status.strip(): + raise ValueError("Agent response missing string 'status'.") + if not isinstance(cont, bool): + raise ValueError("Agent response missing boolean 'continue'.") + if reply is None: + reply = "" + if notify is None: + notify = "" + if not isinstance(reply, str): + raise ValueError("Agent response missing string 'reply'.") + if not isinstance(notify, str): + raise ValueError("Agent response missing string 'notify'.") + return { + "status": _single_line(status), + "continue": cont, + "reply": reply.strip(), + "notify": notify.strip(), + } + + +def _build_wake_prompt(meta, state, session, now, commands, agent_dir): + messages = session.get("pending_messages") or [] + lines = [ + _AGENT_PROMPT, + "", + f"Current UTC time: {format_utc(now)}", + f"Agent name: {meta['name']}", + f"Stop policy: {meta['stop_policy']}", + f"Heartbeat minutes: {meta['heartbeat_minutes']}", + "", + "Original instructions:", + meta["prompt"], + "", + f"Working directory: {meta['cwd']}", + f"Agentbook path: {agent_dir / 'AGENTBOOK.md'}", + "Append a dated note to the agentbook before you respond.", + ] + book = _read_text(agent_dir / "AGENTBOOK.md") + if book.strip(): + lines.extend(["", "Agentbook (latest):", _snippet(book, 3000)]) + if messages: + lines.extend(["", "Queued user messages:"]) + for message in messages: + created_at = message.get("created_at") or "" + author = message.get("author") or "user" + text = message.get("text") or "" + lines.append(f"- [{created_at}] {author}: {text}") + else: + lines.extend(["", "Queued user messages: none."]) + if commands: + lines.extend(["", "Wake triggers:"]) + for command in commands: + lines.append(f"- {command['kind']}") + last_reply = state.get("reply") or "" + if last_reply: + lines.extend(["", "Your last visible reply:", _snippet(last_reply, 1200)]) + lines.extend(["", _AGENT_JSON]) + return "\n".join(lines).strip() + + +def _claim_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + claimed_dir = agent_dir / "commands" / "claimed" + commands = [] + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file(): + continue + target = claimed_dir / path.name + try: + path.rename(target) + except FileNotFoundError: + continue + command = _read_json(target) + command["_path"] = str(target) + commands.append(command) + return commands + + +def _apply_commands(meta, state, session, commands, now): + changed = False + pending = list(session.get("pending_messages") or []) + for command in commands: + kind = command.get("kind") + if kind == "send": + pending.append( + { + "id": command.get("id") or "", + "created_at": command.get("created_at") or format_utc(now), + "author": command.get("author") or "user", + "origin_hostname": command.get("origin_hostname") or "", + "text": command.get("body") or "", + } + ) + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "wake": + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "pause": + state["status"] = "paused" + state["activity"] = "Paused" + changed = True + elif kind == "resume": + if state.get("status") == "paused": + state["status"] = "ready" + state["wake_requested_at"] = format_utc(now) + state["activity"] = "Resumed" + changed = True + elif kind == "cancel": + state["status"] = "canceled" + state["activity"] = "Canceled" + state["wake_requested_at"] = "" + state["next_wake_at"] = "" + changed = True + session["pending_messages"] = pending + _sync_state_from_session(state, session) + for command in commands: + path = command.get("_path") + if path: + try: + os.unlink(path) + except FileNotFoundError: + pass + return changed + + +def _is_due(state, now): + status = state.get("status") + if status not in ("ready", "error"): + return False + if state.get("wake_requested_at"): + return True + if status == "ready" and int(state.get("unread_message_count") or 0) > 0: + return True + next_wake = parse_utc(state.get("next_wake_at")) + if next_wake and next_wake <= now: + return True + return False + + +def _write_run(agent_dir, hostname, payload): + runs_dir = agent_dir / "hosts" / hostname / "runs" + filename = f"{payload['id']}.json" + _write_json(runs_dir / filename, payload) + + +def _recent_runs(agent_dir, limit): + meta = _read_json(agent_dir / "meta.json") + runs_dir = agent_dir / "hosts" / meta["hostname"] / "runs" + if not runs_dir.exists(): + return [] + runs = [] + for path in sorted(runs_dir.iterdir(), key=lambda item: item.name, reverse=True): + if not path.is_file() or path.suffix != ".json": + continue + runs.append(_read_json(path)) + if len(runs) >= limit: + break + return runs + + +def _queue_command(agent_ref, kind, body, author, home, hostname, now): + if kind not in _COMMAND_KINDS: + raise ValueError(f"Unsupported command: {kind}") + agent_dir = resolve_agent_dir(agent_ref, home) + now = now or utc_now() + host = hostname or current_hostname() + author = author or os.environ.get("USER") or "user" + payload = { + "id": _command_id(now, host), + "created_at": format_utc(now), + "origin_hostname": host, + "kind": kind, + "body": body, + "author": str(author), + } + new_dir = agent_dir / "commands" / "new" + _atomic_create_json(new_dir, f"{payload['id']}.json", payload) + return payload + + +def _snapshot(agent_dir): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + return { + "id": meta["id"], + "name": meta["name"], + "created_at": meta["created_at"], + "created_by": meta["created_by"], + "hostname": meta["hostname"], + "cwd": meta["cwd"], + "stop_policy": meta["stop_policy"], + "heartbeat_minutes": meta["heartbeat_minutes"], + "status": state.get("status") or "", + "thread_id": state.get("thread_id") or "", + "last_wake_at": state.get("last_wake_at") or "", + "last_success_at": state.get("last_success_at") or "", + "next_wake_at": state.get("next_wake_at") or "", + "wake_requested_at": state.get("wake_requested_at") or "", + "unread_message_count": int(state.get("unread_message_count") or 0), + "input_tokens": int(state.get("input_tokens") or 0), + "output_tokens": int(state.get("output_tokens") or 0), + "total_tokens": int(state.get("total_tokens") or 0), + "avg_tokens_per_hour": float(state.get("avg_tokens_per_hour") or 0.0), + "last_error": state.get("last_error") or "", + "activity": state.get("activity") or "", + "reply": state.get("reply") or "", + } + + +def _choose_name(home, prompt, requested): + base = _slugify(requested or prompt) + if not base: + base = "agent" + existing = {item["name"] for item in list_agents(home)} + if base not in existing: + return base + index = 2 + while True: + candidate = f"{base}-{index}" + if candidate not in existing: + return candidate + index += 1 + + +def _slugify(text): + if not isinstance(text, str): + return "" + cleaned = [] + for char in text.lower(): + if char.isalnum(): + cleaned.append(char) + continue + cleaned.append("-") + slug = "".join(cleaned) + while "--" in slug: + slug = slug.replace("--", "-") + slug = slug.strip("-") + if not slug: + return "" + parts = [part for part in slug.split("-") if part] + if not parts: + return "" + return "-".join(parts[:6]) + + +def _resolve_cwd(cwd): + target = cwd or os.getcwd() + return str(Path(target).expanduser().resolve()) + + +def _capture_env(): + env = {} + for key in ("PATH", "VIRTUAL_ENV"): + value = os.environ.get(key) + if value: + env[key] = value + return env + + +def _resolve_home(home): + if home is None: + return codexapi_home() + return Path(home).expanduser().resolve() + + +def _ensure_home(home): + (home / "agents").mkdir(parents=True, exist_ok=True) + (home / "locks").mkdir(parents=True, exist_ok=True) + (home / "bin").mkdir(parents=True, exist_ok=True) + (home / "cron").mkdir(parents=True, exist_ok=True) + + +def _agent_dir(home, agent_id): + return home / "agents" / agent_id + + +def _tick_lock_path(home, hostname): + return home / "locks" / f".tick.{hostname}.lock" + + +@contextmanager +def _try_lock(path): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a+", encoding="utf-8") as handle: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield None + return + yield handle + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _write_lock_info(handle, hostname, now): + handle.seek(0) + handle.truncate() + handle.write(json.dumps({"pid": os.getpid(), "hostname": hostname, "started_at": format_utc(now)})) + handle.flush() + + +def _read_session(agent_dir): + meta = _read_json(agent_dir / "meta.json") + return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") + + +def _sync_state_from_session(state, session): + pending = session.get("pending_messages") or [] + state["unread_message_count"] = len(pending) + state["thread_id"] = session.get("thread_id") or "" + + +def _command_id(now, hostname): + stamp = now.strftime("%Y%m%dT%H%M%SZ") + rand = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) + return f"{stamp}.{hostname}.{os.getpid()}.{rand}" + + +def _run_id(now): + return f"{now.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + + +def _atomic_create_json(directory, filename, payload): + directory.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, directory / filename) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_text(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _read_json(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def _read_text(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + except FileNotFoundError: + return "" + + +def _snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return text[: limit - 3] + "..." + + +def _strip_fence(text): + if not text.startswith("```"): + return text + lines = text.splitlines() + if len(lines) < 3: + return text + if lines[-1].strip() != "```": + return text + return "\n".join(lines[1:-1]).strip() + + +def _single_line(text): + if not text: + return "" + return " ".join(str(text).replace("\r", " ").split()) + + +def _wake_reason(state, commands): + reasons = [] + if state.get("wake_requested_at"): + reasons.append("wake_requested") + if int(state.get("unread_message_count") or 0) > 0: + reasons.append("messages") + if commands: + reasons.append("commands") + if not reasons: + reasons.append("heartbeat") + return ",".join(sorted(set(reasons))) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f0ac111..efc9a38 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -13,6 +13,15 @@ from pathlib import Path from .agent import Agent, agent +from .agents import ( + control_agent, + list_agents as list_managed_agents, + read_agent as read_managed_agent, + send_agent, + show_agent as show_managed_agent, + start_agent as start_managed_agent, + tick as tick_managed_agents, +) from .foreach import foreach from .ralph import Ralph, cancel_ralph_loop from .science import Science @@ -131,6 +140,35 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) +def _print_managed_agent_list(items): + if not items: + print("No agents.") + return + print("ID STAT HOST UNREAD TOKENS NAME") + for item in items: + ident = item["id"][:8] + status = _truncate_head(item["status"] or "-", 8) + host = _truncate_head(item["hostname"] or "-", 15) + unread = str(item["unread_message_count"]) + tokens = _format_token_total(item["total_tokens"]) + name = item["name"] + print(f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {name}") + + +def _print_managed_agent_read(result): + print(f"{result['name']} [{result['status']}]") + items = result.get("items") or [] + if not items: + print("No messages.") + return + for item in items: + stamp = item.get("timestamp") or "-" + kind = item.get("kind") or "item" + print(f"[{stamp}] {kind}:") + print(item.get("text") or "") + print() + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -1140,6 +1178,101 @@ def main(argv=None): help="Print the current thread id to stderr after running.", ) + agent_parser = subparsers.add_parser( + "agent", + help="Manage durable long-running agents.", + ) + agent_subparsers = agent_parser.add_subparsers(dest="agent_command") + + agent_start = agent_subparsers.add_parser( + "start", + help="Create a durable agent.", + ) + agent_start.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + agent_start.add_argument("--cwd", help="Working directory for the agent.") + agent_start.add_argument("--name", help="Optional agent name.") + agent_start.add_argument( + "--created-by", + help="Creator label (defaults to $USER).", + ) + agent_start.add_argument( + "--stop-policy", + default="until_done", + choices=("until_done", "until_stopped"), + help="Whether the agent stops itself when done or runs until stopped.", + ) + agent_start.add_argument( + "--heartbeat-minutes", + type=int, + default=5, + help="Heartbeat interval in minutes (default: 5).", + ) + agent_start.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) + agent_start.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo (Codex uses --full-auto).", + ) + agent_start.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the backend.", + ) + + agent_subparsers.add_parser( + "list", + help="List durable agents in this CODEXAPI_HOME.", + ) + + agent_show = agent_subparsers.add_parser( + "show", + help="Show one durable agent.", + ) + agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_read = agent_subparsers.add_parser( + "read", + help="Read recent visible communication for one agent.", + ) + agent_read.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_read.add_argument( + "--limit", + type=int, + default=10, + help="Maximum number of items to show (default: 10).", + ) + + agent_send = agent_subparsers.add_parser( + "send", + help="Queue a message for an agent.", + ) + agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_send.add_argument("message", help="Message to queue.") + agent_send.add_argument("--author", help="Author label for the message.") + + for subcommand, help_text in ( + ("wake", "Request an extra wake for an agent."), + ("pause", "Pause an agent."), + ("resume", "Resume a paused agent."), + ("cancel", "Cancel an agent."), + ): + subparser = agent_subparsers.add_parser(subcommand, help=help_text) + subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + subparser.add_argument("--author", help="Author label for the command.") + + agent_subparsers.add_parser( + "tick", + help="Process due agents for the current host.", + ) + task_parser = subparsers.add_parser( "task", help="Run a task with verification retries.", @@ -1435,6 +1568,51 @@ def main(argv=None): if args.command is None: parser.print_help() raise SystemExit(2) + if args.command == "agent": + if args.agent_command is None: + agent_parser.print_help() + raise SystemExit(2) + if args.agent_command == "start": + prompt = _read_prompt(args.prompt) + result = start_managed_agent( + prompt, + args.cwd, + args.name, + args.created_by, + args.stop_policy, + args.heartbeat_minutes, + args.backend, + args.yolo, + args.flags, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "list": + _print_managed_agent_list(list_managed_agents()) + return + if args.agent_command == "show": + print(json.dumps(show_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + return + if args.agent_command == "read": + if args.limit < 1: + raise SystemExit("--limit must be >= 1.") + _print_managed_agent_read(read_managed_agent(args.agent_ref, args.limit)) + return + if args.agent_command == "send": + result = send_agent(args.agent_ref, args.message, args.author) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("wake", "pause", "resume", "cancel"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "tick": + print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..f96c2d1 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,131 @@ +import json +import os +import sys +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.agents import ( + _tick_lock_path, + _try_lock, + control_agent, + read_agent, + send_agent, + show_agent, + start_agent, + tick, +) + + +@contextmanager +def _temp_home(): + with tempfile.TemporaryDirectory() as tmpdir: + with patch.dict(os.environ, {"CODEXAPI_HOME": tmpdir, "USER": "tester"}, clear=False): + yield Path(tmpdir) + + +class AgentsTests(unittest.TestCase): + def test_homes_are_isolated(self): + with _temp_home() as home_a: + first = start_agent("Monitor the build queue.", hostname="host-a") + self.assertEqual(first["name"], "monitor-the-build-queue") + agents_a = show_agent(first["id"]) + self.assertEqual(agents_a["meta"]["hostname"], "host-a") + with _temp_home() as home_b: + with self.assertRaises(ValueError): + show_agent(first["id"]) + second = start_agent("Watch CI failures.", hostname="host-b") + self.assertNotEqual(first["id"], second["id"]) + + def test_cross_host_message_waits_for_owner_tick(self): + prompts = [] + + def fake_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Replied", + "continue": False, + "reply": "I saw your message.", + } + ), + "thread_id": "thread-abc", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + other_host = tick(hostname="host-b", runner=fake_runner) + self.assertTrue(other_host["ran"]) + self.assertEqual(other_host["processed"], 0) + self.assertEqual(other_host["woken"], 0) + + owner = tick(hostname="host-a", runner=fake_runner) + self.assertTrue(owner["ran"]) + self.assertEqual(owner["woken"], 1) + self.assertEqual(len(prompts), 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["thread_id"], "thread-abc") + self.assertEqual(shown["state"]["reply"], "I saw your message.") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + conversation = read_agent(agent["id"]) + self.assertEqual(conversation["items"][0]["kind"], "agent") + self.assertEqual(conversation["items"][0]["text"], "I saw your message.") + + def test_pause_then_resume(self): + calls = [] + + def fake_runner(meta, session, prompt): + calls.append(prompt) + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-xyz", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-b") + paused = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(paused["processed"], 1) + self.assertEqual(paused["woken"], 0) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "paused") + self.assertEqual(calls, []) + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(resumed["woken"], 1) + self.assertEqual(len(calls), 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + + def test_tick_lock_is_non_blocking(self): + with _temp_home() as home: + start_agent("Do the thing.", hostname="host-a") + lock_path = _tick_lock_path(home, "host-a") + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + result = tick(hostname="host-a") + self.assertFalse(result["ran"]) + self.assertEqual(result["processed"], 0) + self.assertEqual(result["woken"], 0) + + +if __name__ == "__main__": + unittest.main() From 765017b2e2320347c27703f966d95016cfeeb7d5 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:14:19 +0100 Subject: [PATCH 50/78] Add cron installer for durable agents --- src/codexapi/agents.py | 107 +++++++++++++++++++++++++++++++++++++++++ src/codexapi/cli.py | 8 +++ tests/test_agents.py | 52 ++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 4092958..066e683 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,12 +3,16 @@ import json import os import random +import shlex import socket import string +import subprocess +import sys import tempfile import uuid from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from hashlib import sha1 from pathlib import Path import fcntl @@ -289,6 +293,30 @@ def tick(home=None, hostname=None, now=None, runner=None): return {"ran": True, "hostname": host, "processed": processed, "woken": woken} +def install_cron(home=None, hostname=None, python_executable=None, path_value=None): + """Install or update the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + wrapper = write_tick_wrapper(home, python_executable, path_value) + cron_line = render_cron_line(home, host) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _upsert_cron_line(existing, cron_line, tag) + if changed: + _write_crontab(updated) + _write_text(home / "cron" / "agent.cron", cron_line + "\n") + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "cron_line": cron_line, + "changed": changed, + } + + def resolve_agent_dir(agent_ref, home=None): """Resolve an agent by id, unique id prefix, or name.""" if not isinstance(agent_ref, str) or not agent_ref.strip(): @@ -312,6 +340,32 @@ def resolve_agent_dir(agent_ref, home=None): return _agent_dir(home, matches[0]) +def write_tick_wrapper(home=None, python_executable=None, path_value=None): + """Write the cron wrapper script and return its path.""" + home = _resolve_home(home) + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + wrapper = home / "bin" / "agent-tick" + lines = [ + "#!/bin/bash", + f"export CODEXAPI_HOME={shlex.quote(str(home))}", + f"export PATH={shlex.quote(path_value)}", + f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", + ] + _write_text(wrapper, "\n".join(lines) + "\n") + wrapper.chmod(0o755) + return wrapper + + +def render_cron_line(home=None, hostname=None): + """Return the cron line for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + wrapper = home / "bin" / "agent-tick" + return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }" + + def _tick_agent(agent_dir, now, runner): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") @@ -862,3 +916,56 @@ def _wake_reason(state, commands): if not reasons: reasons.append("heartbeat") return ",".join(sorted(set(reasons))) + + +def _cron_tag(home, hostname): + key = sha1(str(home).encode("utf-8")).hexdigest()[:12] + return f"codexapi-agent::{hostname}::{key}" + + +def _upsert_cron_line(existing, line, tag): + lines = [] + found = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + if not found: + lines.append(line) + found = True + continue + lines.append(raw) + if not found: + lines.append(line) + found = True + changed = True + else: + changed = "\n".join(lines).strip() != str(existing or "").strip() + text = "\n".join(item for item in lines if item is not None) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + +def _read_crontab(): + result = subprocess.run( + ["crontab", "-l"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout + stderr = (result.stderr or "").strip().lower() + stdout = (result.stdout or "").strip().lower() + if "no crontab" in stderr or "no crontab" in stdout: + return "" + raise RuntimeError(result.stderr.strip() or "crontab -l failed") + + +def _write_crontab(text): + result = subprocess.run( + ["crontab", "-"], + input=text, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "crontab install failed") diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index efc9a38..8bb8a9a 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -15,6 +15,7 @@ from .agent import Agent, agent from .agents import ( control_agent, + install_cron as install_agent_cron, list_agents as list_managed_agents, read_agent as read_managed_agent, send_agent, @@ -1272,6 +1273,10 @@ def main(argv=None): "tick", help="Process due agents for the current host.", ) + agent_subparsers.add_parser( + "install-cron", + help="Install or update the cron entry for this CODEXAPI_HOME.", + ) task_parser = subparsers.add_parser( "task", @@ -1613,6 +1618,9 @@ def main(argv=None): if args.agent_command == "tick": print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) return + if args.agent_command == "install-cron": + print(json.dumps(install_agent_cron(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index f96c2d1..15ebc34 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -12,12 +12,16 @@ from codexapi.agents import ( _tick_lock_path, _try_lock, + _upsert_cron_line, control_agent, + install_cron, read_agent, + render_cron_line, send_agent, show_agent, start_agent, tick, + write_tick_wrapper, ) @@ -126,6 +130,54 @@ def test_tick_lock_is_non_blocking(self): self.assertEqual(result["processed"], 0) self.assertEqual(result["woken"], 0) + def test_write_tick_wrapper_pins_home_and_python(self): + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + text = wrapper.read_text(encoding="utf-8") + self.assertIn("export CODEXAPI_HOME=", text) + self.assertIn(str(home), text) + self.assertIn("export PATH=", text) + self.assertIn("/tmp/venv/bin:/usr/bin", text) + self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) + + def test_upsert_cron_line_keeps_different_homes_separate(self): + line_a = render_cron_line(home="/tmp/home-a", hostname="host-a") + line_b = render_cron_line(home="/tmp/home-b", hostname="host-a") + updated, changed = _upsert_cron_line("", line_a, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + updated, changed = _upsert_cron_line(updated, line_b, "codexapi-agent::host-a::bbb") + self.assertTrue(changed) + self.assertIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + + def test_install_cron_writes_wrapper_and_updates_crontab_text(self): + writes = [] + + def fake_read(): + return "" + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + wrapper = Path(result["wrapper"]) + self.assertTrue(wrapper.exists()) + self.assertEqual(len(writes), 1) + self.assertIn(str(wrapper), writes[0]) + self.assertIn("codexapi-agent::host-a::", writes[0]) + if __name__ == "__main__": unittest.main() From 52c50153c5a7186d725a91affc7593af9a6b8c2d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:15:20 +0100 Subject: [PATCH 51/78] Improve agent scheduler visibility and install flow --- src/codexapi/agents.py | 33 ++++++++++++++++++++++++++++++++- tests/test_agents.py | 7 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 066e683..ee2cf8c 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -206,6 +206,7 @@ def show_agent(agent_ref, home=None): snapshot = _snapshot(agent_dir) snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) return snapshot @@ -218,6 +219,16 @@ def read_agent(agent_ref, limit=10, home=None): state = _read_json(agent_dir / "state.json") session = _read_session(agent_dir) items = [] + for queued in _queued_send_commands(agent_dir): + text = queued.get("body") or "" + if text: + items.append( + { + "kind": "queued", + "timestamp": queued.get("created_at") or "", + "text": text, + } + ) for run in _recent_runs(agent_dir, limit): reply = run.get("reply") or "" if reply: @@ -682,6 +693,9 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): def _snapshot(agent_dir): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + unread = int(state.get("unread_message_count") or 0) + len( + _queued_send_commands(agent_dir) + ) return { "id": meta["id"], "name": meta["name"], @@ -697,7 +711,7 @@ def _snapshot(agent_dir): "last_success_at": state.get("last_success_at") or "", "next_wake_at": state.get("next_wake_at") or "", "wake_requested_at": state.get("wake_requested_at") or "", - "unread_message_count": int(state.get("unread_message_count") or 0), + "unread_message_count": unread, "input_tokens": int(state.get("input_tokens") or 0), "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), @@ -918,6 +932,23 @@ def _wake_reason(state, commands): return ",".join(sorted(set(reasons))) +def _queued_send_commands(agent_dir): + queued = [] + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return queued + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file() or path.suffix != ".json": + continue + try: + payload = _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + continue + if payload.get("kind") == "send": + queued.append(payload) + return queued + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/tests/test_agents.py b/tests/test_agents.py index 15ebc34..a1fa6b4 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -65,6 +65,13 @@ def fake_runner(meta, session, prompt): agent = start_agent("Handle background work.", hostname="host-a") send_agent(agent["id"], "status", author="mark", hostname="host-b") + before = show_agent(agent["id"]) + self.assertEqual(before["unread_message_count"], 1) + self.assertEqual(before["state"]["unread_message_count"], 1) + queued = read_agent(agent["id"]) + self.assertEqual(queued["items"][0]["kind"], "queued") + self.assertEqual(queued["items"][0]["text"], "status") + other_host = tick(hostname="host-b", runner=fake_runner) self.assertTrue(other_host["ran"]) self.assertEqual(other_host["processed"], 0) From 75d7f7e3abb36492975d7312df4cf33d2fd8af0d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:54:38 +0100 Subject: [PATCH 52/78] Add immediate nudges and token accounting --- src/codexapi/agent.py | 89 +++++++++++++++++++++++++++++++++-- src/codexapi/agents.py | 92 ++++++++++++++++++++++++++++++++++++- src/codexapi/cli.py | 3 ++ tests/test_agent_backend.py | 83 +++++++++++++++++++++++++++++++++ tests/test_agents.py | 48 +++++++++++++++++++ 5 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 tests/test_agent_backend.py diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 6ab2f11..0b87b14 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -47,7 +47,7 @@ def agent( Returns: The agent's visible response text with reasoning traces removed. """ - message, _thread_id = _run_agent( + message, _thread_id, _usage = _run_agent( prompt, cwd, None, yolo, flags, include_thinking, backend, env ) return message @@ -103,12 +103,13 @@ def __init__( self.thread_id = thread_id self._backend = backend self._env = env + self.last_usage = {} def __call__(self, prompt): """Send a prompt to the agent backend and return the message.""" if self._welfare: prompt = welfare.append_instructions(prompt) - message, thread_id = _run_agent( + message, thread_id, usage = _run_agent( prompt, self.cwd, self.thread_id, @@ -120,6 +121,7 @@ def __call__(self, prompt): ) if thread_id: self.thread_id = thread_id + self.last_usage = usage or {} if self._welfare and welfare.stop_requested(message): raise WelfareStop(message) return message @@ -213,6 +215,7 @@ def _parse_jsonl(output, include_thinking): thread_id = None messages = [] raw_lines = [] + usage = {} for line in output.splitlines(): line = line.strip() @@ -229,6 +232,10 @@ def _parse_jsonl(output, include_thinking): if isinstance(maybe_thread, str): thread_id = maybe_thread + maybe_usage = _event_usage(event) + if maybe_usage: + usage = maybe_usage + if event.get("type") == "item.completed": item = event.get("item") or {} if item.get("type") == "agent_message": @@ -243,8 +250,8 @@ def _parse_jsonl(output, include_thinking): ) if include_thinking: - return "\n\n".join(messages), thread_id - return messages[-1], thread_id + return "\n\n".join(messages), thread_id, usage + return messages[-1], thread_id, usage def _parse_cursor_json(output, include_thinking): @@ -292,7 +299,7 @@ def _parse_cursor_json(output, include_thinking): session_id = payload.get("session_id") if not isinstance(session_id, str): session_id = None - return result, session_id + return result, session_id, {} def _merged_env(env): @@ -308,3 +315,75 @@ def _merged_env(env): else: merged[str(key)] = str(value) return merged + + +def _event_usage(event): + """Extract per-call token usage from a backend event when present.""" + if not isinstance(event, dict): + return {} + event_type = event.get("type") + payload = None + if event_type == "event_msg": + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + return {} + info = payload.get("info") or {} + usage = info.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = info.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + if event_type == "token_count": + payload = event.get("info") or event.get("payload") or {} + usage = payload.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = payload.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + + +def _normalize_usage(usage): + """Normalize token usage dicts to input/output/total ints.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int( + usage.get("input_tokens"), + usage.get("prompt_tokens"), + usage.get("input"), + ) + output_tokens = _usage_int( + usage.get("output_tokens"), + usage.get("completion_tokens"), + usage.get("output"), + ) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _usage_int(*values): + """Return the first integer-like usage value from the given candidates.""" + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index ee2cf8c..c39c9d8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -280,6 +280,22 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): + """Attempt an immediate wake for one locally-owned agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + outcome = _tick_agent(agent_dir, now or utc_now(), runner) + return { + "ran": True, + "reason": "local", + "processed": 1 if outcome["processed"] else 0, + "woken": 1 if outcome["woken"] else 0, + } + + def tick(home=None, hostname=None, now=None, runner=None): """Process due agents for the current host.""" home = _resolve_home(home) @@ -431,6 +447,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): "notify": "", "error": "", "continue": True, + "usage": {}, } try: outcome = _run_agent_turn(meta, session, prompt, runner) @@ -438,6 +455,8 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): ended = utc_now() session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" session["pending_messages"] = [] + usage = _normalize_usage(outcome.get("usage")) + _add_usage(meta, state, usage, ended) state["reply"] = response["reply"] state["last_success_at"] = format_utc(ended) state["last_error"] = "" @@ -460,6 +479,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): run["reply"] = response["reply"] run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) + run["usage"] = usage _write_run(agent_dir, meta["hostname"], run) if response["notify"]: title = f"Agent: {meta['name']}" @@ -483,7 +503,10 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): def _run_agent_turn(meta, session, prompt, runner=None): if runner is not None: - return runner(meta, session, prompt) + outcome = runner(meta, session, prompt) + if not isinstance(outcome, dict): + raise TypeError("runner must return a dict") + return outcome worker = Agent( session.get("cwd") or meta.get("cwd"), session.get("yolo", True), @@ -494,7 +517,11 @@ def _run_agent_turn(meta, session, prompt, runner=None): env=session.get("env") or None, ) message = worker(prompt) - return {"message": message, "thread_id": worker.thread_id or ""} + return { + "message": message, + "thread_id": worker.thread_id or "", + "usage": worker.last_usage or {}, + } def _parse_agent_response(output): @@ -932,6 +959,67 @@ def _wake_reason(state, commands): return ",".join(sorted(set(reasons))) +def _normalize_usage(usage): + """Normalize usage dicts for state accounting.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int(usage.get("input_tokens")) + output_tokens = _usage_int(usage.get("output_tokens")) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _add_usage(meta, state, usage, now): + """Accumulate token usage totals and refresh the running average.""" + if not usage: + return + input_tokens = usage.get("input_tokens") + output_tokens = usage.get("output_tokens") + total_tokens = usage.get("total_tokens") + if input_tokens is not None: + state["input_tokens"] = int(state.get("input_tokens") or 0) + input_tokens + if output_tokens is not None: + state["output_tokens"] = int(state.get("output_tokens") or 0) + output_tokens + if total_tokens is None: + total_tokens = 0 + if input_tokens is not None: + total_tokens += input_tokens + if output_tokens is not None: + total_tokens += output_tokens + state["total_tokens"] = int(state.get("total_tokens") or 0) + total_tokens + created_at = parse_utc(meta.get("created_at")) + if created_at is None: + return + elapsed = (now - created_at).total_seconds() + if elapsed <= 0: + elapsed = 1 + state["avg_tokens_per_hour"] = round(state["total_tokens"] * 3600.0 / elapsed, 2) + + +def _usage_int(value): + """Return an integer-like usage value or None.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None + + def _queued_send_commands(agent_dir): queued = [] new_dir = agent_dir / "commands" / "new" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 8bb8a9a..9495ca0 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -17,6 +17,7 @@ control_agent, install_cron as install_agent_cron, list_agents as list_managed_agents, + nudge_agent, read_agent as read_managed_agent, send_agent, show_agent as show_managed_agent, @@ -1605,6 +1606,7 @@ def main(argv=None): return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("wake", "pause", "resume", "cancel"): @@ -1613,6 +1615,7 @@ def main(argv=None): args.agent_command, args.author, ) + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "tick": diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py new file mode 100644 index 0000000..ae443b3 --- /dev/null +++ b/tests/test_agent_backend.py @@ -0,0 +1,83 @@ +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.agent import _parse_jsonl + + +class AgentBackendTests(unittest.TestCase): + def test_parse_jsonl_extracts_last_token_usage(self): + output = "\n".join( + [ + json.dumps({"type": "thread.started", "thread_id": "thread-1"}), + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 12, + "output_tokens": 7, + "total_tokens": 19, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "hello"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "hello") + self.assertEqual(thread_id, "thread-1") + self.assertEqual( + usage, + {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19}, + ) + + def test_parse_jsonl_falls_back_to_total_token_usage(self): + output = "\n".join( + [ + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "prompt_tokens": 9, + "completion_tokens": 4, + "total_tokens": 13, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "done"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "done") + self.assertIsNone(thread_id) + self.assertEqual( + usage, + {"input_tokens": 9, "output_tokens": 4, "total_tokens": 13}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agents.py b/tests/test_agents.py index a1fa6b4..04e66bc 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -4,6 +4,7 @@ import tempfile import unittest from contextlib import contextmanager +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -15,6 +16,7 @@ _upsert_cron_line, control_agent, install_cron, + nudge_agent, read_agent, render_cron_line, send_agent, @@ -185,6 +187,52 @@ def fake_write(text): self.assertIn(str(wrapper), writes[0]) self.assertIn("codexapi-agent::host-a::", writes[0]) + def test_nudge_agent_runs_immediately_and_updates_token_totals(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-usage") + self.assertEqual(shown["state"]["input_tokens"], 30) + self.assertEqual(shown["state"]["output_tokens"], 20) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Message handled.") + self.assertEqual(shown["unread_message_count"], 0) + if __name__ == "__main__": unittest.main() From 1d234ac5565d78cb9ab62e2b2aab8800c95c788c Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 09:01:58 +0100 Subject: [PATCH 53/78] Improve agent transcripts and scheduler lifecycle --- src/codexapi/agents.py | 72 ++++++++++++++++++++++++++++++++++++++++-- src/codexapi/cli.py | 41 ++++++++++++++++++++++-- tests/test_agents.py | 72 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index c39c9d8..6a43afc 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -227,9 +227,21 @@ def read_agent(agent_ref, limit=10, home=None): "kind": "queued", "timestamp": queued.get("created_at") or "", "text": text, + "author": queued.get("author") or "user", } ) for run in _recent_runs(agent_dir, limit): + for message in run.get("messages") or []: + text = message.get("text") or "" + if text: + items.append( + { + "kind": "user", + "timestamp": message.get("created_at") or "", + "text": text, + "author": message.get("author") or "user", + } + ) reply = run.get("reply") or "" if reply: items.append( @@ -247,14 +259,15 @@ def read_agent(agent_ref, limit=10, home=None): "kind": "pending", "timestamp": pending.get("created_at") or "", "text": text, + "author": pending.get("author") or "user", } ) - items.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + items.sort(key=lambda item: item.get("timestamp") or "") return { "id": meta["id"], "name": meta["name"], "status": state.get("status") or "", - "items": items[:limit], + "items": items[-limit:], } @@ -344,6 +357,28 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } +def uninstall_cron(home=None, hostname=None): + """Remove the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _remove_cron_line(existing, tag) + if changed: + _write_crontab(updated) + wrapper = home / "bin" / "agent-tick" + cron_record = home / "cron" / "agent.cron" + _remove_file(wrapper) + _remove_file(cron_record) + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "changed": changed, + } + + def resolve_agent_dir(agent_ref, home=None): """Resolve an agent by id, unique id prefix, or name.""" if not isinstance(agent_ref, str) or not agent_ref.strip(): @@ -442,6 +477,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): "ended_at": "", "wake_reason": _wake_reason(state, commands), "commands": [command["kind"] for command in commands], + "messages": [], "status": "", "reply": "", "notify": "", @@ -453,6 +489,16 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): outcome = _run_agent_turn(meta, session, prompt, runner) response = _parse_agent_response(outcome["message"]) ended = utc_now() + delivered_messages = [ + { + "id": message.get("id") or "", + "created_at": message.get("created_at") or "", + "author": message.get("author") or "user", + "text": message.get("text") or "", + } + for message in (session.get("pending_messages") or []) + if message.get("text") + ] session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" session["pending_messages"] = [] usage = _normalize_usage(outcome.get("usage")) @@ -480,6 +526,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) run["usage"] = usage + run["messages"] = delivered_messages _write_run(agent_dir, meta["hostname"], run) if response["notify"]: title = f"Agent: {meta['name']}" @@ -1064,6 +1111,20 @@ def _upsert_cron_line(existing, line, tag): return text, changed +def _remove_cron_line(existing, tag): + lines = [] + changed = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + changed = True + continue + lines.append(raw) + text = "\n".join(lines) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + def _read_crontab(): result = subprocess.run( ["crontab", "-l"], @@ -1088,3 +1149,10 @@ def _write_crontab(text): ) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or "crontab install failed") + + +def _remove_file(path): + try: + Path(path).unlink() + except FileNotFoundError: + return diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 9495ca0..2206674 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -23,6 +23,7 @@ show_agent as show_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, + uninstall_cron as uninstall_agent_cron, ) from .foreach import foreach from .ralph import Ralph, cancel_ralph_loop @@ -146,15 +147,18 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT HOST UNREAD TOKENS NAME") + print("ID STAT HOST UNREAD TOKENS TOK/H NAME") for item in items: ident = item["id"][:8] status = _truncate_head(item["status"] or "-", 8) host = _truncate_head(item["hostname"] or "-", 15) unread = str(item["unread_message_count"]) tokens = _format_token_total(item["total_tokens"]) + tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) name = item["name"] - print(f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {name}") + print( + f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {tok_h:>7} {name}" + ) def _print_managed_agent_read(result): @@ -166,7 +170,11 @@ def _print_managed_agent_read(result): for item in items: stamp = item.get("timestamp") or "-" kind = item.get("kind") or "item" - print(f"[{stamp}] {kind}:") + author = item.get("author") or "" + if author: + print(f"[{stamp}] {kind} {author}:") + else: + print(f"[{stamp}] {kind}:") print(item.get("text") or "") print() @@ -475,6 +483,26 @@ def _format_token_total(value): return str(value) +def _format_token_rate(value): + if value is None: + return "-" + try: + value = float(value) + except (TypeError, ValueError): + return "-" + if value < 0: + return "-" + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}m" + if value >= 1_000: + return f"{value / 1_000:.1f}k" + if value >= 100: + return f"{value:.0f}" + if value >= 10: + return f"{value:.1f}" + return f"{value:.2f}" + + def _format_duration(seconds): if seconds is None: return "-" @@ -1278,6 +1306,10 @@ def main(argv=None): "install-cron", help="Install or update the cron entry for this CODEXAPI_HOME.", ) + agent_subparsers.add_parser( + "uninstall-cron", + help="Remove the cron entry for this CODEXAPI_HOME.", + ) task_parser = subparsers.add_parser( "task", @@ -1624,6 +1656,9 @@ def main(argv=None): if args.agent_command == "install-cron": print(json.dumps(install_agent_cron(), indent=2, sort_keys=True)) return + if args.agent_command == "uninstall-cron": + print(json.dumps(uninstall_agent_cron(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 04e66bc..0354ea9 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -13,6 +13,7 @@ from codexapi.agents import ( _tick_lock_path, _try_lock, + _remove_cron_line, _upsert_cron_line, control_agent, install_cron, @@ -23,6 +24,7 @@ show_agent, start_agent, tick, + uninstall_cron, write_tick_wrapper, ) @@ -92,8 +94,11 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["unread_message_count"], 0) conversation = read_agent(agent["id"]) - self.assertEqual(conversation["items"][0]["kind"], "agent") - self.assertEqual(conversation["items"][0]["text"], "I saw your message.") + self.assertEqual(conversation["items"][0]["kind"], "user") + self.assertEqual(conversation["items"][0]["author"], "mark") + self.assertEqual(conversation["items"][0]["text"], "status") + self.assertEqual(conversation["items"][1]["kind"], "agent") + self.assertEqual(conversation["items"][1]["text"], "I saw your message.") def test_pause_then_resume(self): calls = [] @@ -163,6 +168,16 @@ def test_upsert_cron_line_keeps_different_homes_separate(self): self.assertIn("/tmp/home-a/bin/agent-tick", updated) self.assertIn("/tmp/home-b/bin/agent-tick", updated) + def test_remove_cron_line_keeps_other_entries(self): + existing = ( + "* * * * * /tmp/home-a/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::aaa\n" + "* * * * * /tmp/home-b/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::bbb\n" + ) + updated, changed = _remove_cron_line(existing, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + self.assertNotIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + def test_install_cron_writes_wrapper_and_updates_crontab_text(self): writes = [] @@ -187,6 +202,59 @@ def fake_write(text): self.assertIn(str(wrapper), writes[0]) self.assertIn("codexapi-agent::host-a::", writes[0]) + def test_install_cron_is_idempotent_when_line_already_matches(self): + writes = [] + + with _temp_home() as home: + expected_line = render_cron_line(home=home, hostname="host-a") + + def fake_read(): + return expected_line + "\n" + + def fake_write(text): + writes.append(text) + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + self.assertFalse(result["changed"]) + self.assertEqual(writes, []) + + def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self): + writes = [] + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + record = home / "cron" / "agent.cron" + record.write_text("placeholder\n", encoding="utf-8") + this_line = render_cron_line(home=home, hostname="host-a") + other_line = render_cron_line(home="/tmp/other-home", hostname="host-a") + + def fake_read(): + return this_line + "\n" + other_line + "\n" + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = uninstall_cron(home=home, hostname="host-a") + self.assertTrue(result["changed"]) + self.assertFalse(wrapper.exists()) + self.assertFalse(record.exists()) + self.assertEqual(len(writes), 1) + self.assertNotIn(str(wrapper), writes[0]) + self.assertIn("/tmp/other-home/bin/agent-tick", writes[0]) + def test_nudge_agent_runs_immediately_and_updates_token_totals(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(hours=1) From 77d4ab6beb54b6c9f7935a6c7eb5c7d8a60495a6 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 09:30:07 +0100 Subject: [PATCH 54/78] Use rollout logs for agent usage and improve views --- src/codexapi/agents.py | 86 +++++++++++++++- src/codexapi/cli.py | 122 ++++++++++++++++++++++- tests/test_agents.py | 216 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 419 insertions(+), 5 deletions(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 6a43afc..cffb475 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -135,6 +135,7 @@ def start_agent( cwd = _resolve_cwd(cwd) session = { "thread_id": "", + "rollout_path": "", "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), "yolo": bool(yolo), "flags": flags or "", @@ -500,6 +501,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): if message.get("text") ] session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" + session["rollout_path"] = outcome.get("rollout_path") or session.get("rollout_path") or "" session["pending_messages"] = [] usage = _normalize_usage(outcome.get("usage")) _add_usage(meta, state, usage, ended) @@ -554,6 +556,7 @@ def _run_agent_turn(meta, session, prompt, runner=None): if not isinstance(outcome, dict): raise TypeError("runner must return a dict") return outcome + started = utc_now() worker = Agent( session.get("cwd") or meta.get("cwd"), session.get("yolo", True), @@ -564,10 +567,21 @@ def _run_agent_turn(meta, session, prompt, runner=None): env=session.get("env") or None, ) message = worker(prompt) + usage = worker.last_usage or {} + rollout_path = "" + if (session.get("backend") or "codex") == "codex": + rollout_usage, rollout_path = _codex_rollout_usage( + session, + worker.thread_id or session.get("thread_id") or "", + started, + ) + if rollout_usage: + usage = rollout_usage return { "message": message, "thread_id": worker.thread_id or "", - "usage": worker.last_usage or {}, + "usage": usage, + "rollout_path": rollout_path, } @@ -1084,6 +1098,76 @@ def _queued_send_commands(agent_dir): return queued +def _codex_rollout_usage(session, thread_id, started_at): + """Return usage from the current Codex rollout plus its resolved path.""" + if not thread_id: + return {}, "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is None: + return {}, "" + usage = _extract_rollout_usage(rollout_path, started_at) + if not usage: + return {}, str(rollout_path) + return usage, str(rollout_path) + + +def _resolve_rollout_path(known_path, thread_id): + """Return the rollout file for a thread, preferring the cached session path.""" + if known_path: + path = Path(known_path) + if path.exists() and thread_id in path.name: + return path + root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions" + if not root.exists(): + return None + candidates = [] + for dirpath, _dirnames, filenames in os.walk(root): + for name in filenames: + if not name.startswith("rollout-") or not name.endswith(".jsonl"): + continue + if thread_id not in name: + continue + path = Path(dirpath) / name + try: + mtime = path.stat().st_mtime + except OSError: + continue + candidates.append((mtime, path)) + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +def _extract_rollout_usage(path, started_at): + """Return the latest per-turn token usage written after this wake started.""" + latest = {} + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if '"token_count"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "event_msg": + continue + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + continue + timestamp = parse_utc(event.get("timestamp")) + if timestamp is not None and timestamp < started_at: + continue + info = payload.get("info") or {} + usage = _normalize_usage(info.get("last_token_usage")) + if usage: + latest = usage + except OSError: + return {} + return latest + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 2206674..9623c82 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -147,17 +147,20 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT HOST UNREAD TOKENS TOK/H NAME") + print("ID STAT POL HOST UNR TOKENS TOK/H NEXT REPO NAME") for item in items: ident = item["id"][:8] status = _truncate_head(item["status"] or "-", 8) - host = _truncate_head(item["hostname"] or "-", 15) + policy = _truncate_head(_policy_label(item.get("stop_policy")), 4) + host = _truncate_head(item["hostname"] or "-", 12) unread = str(item["unread_message_count"]) tokens = _format_token_total(item["total_tokens"]) tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) + next_wake = _truncate_head(_next_wake_label(item), 6) + repo = _truncate_head(_repo_label(item.get("cwd")), 12) name = item["name"] print( - f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {tok_h:>7} {name}" + f"{ident:<8} {status:<8} {policy:<4} {host:<12} {unread:>3} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" ) @@ -179,6 +182,42 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_show(result): + meta = result["meta"] + state = result["state"] + print(f"{meta['name']} [{state.get('status') or '-'}]") + print(f"ID: {meta['id']}") + print(f"Host: {meta['hostname']}") + print(f"Created: {meta['created_at']} by {meta['created_by']}") + print( + f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" + ) + print(f"CWD: {meta['cwd']}") + print(f"Thread: {state.get('thread_id') or '-'}") + print( + "Tokens: " + f"{_format_token_total(state.get('total_tokens'))} total " + f"({_format_token_total(state.get('input_tokens'))} in, " + f"{_format_token_total(state.get('output_tokens'))} out, " + f"{_format_token_rate(state.get('avg_tokens_per_hour'))}/h)" + ) + print(f"Activity: {_state_text(state.get('activity'))}") + print(f"Reply: {_state_text(state.get('reply'))}") + print(f"Last error: {_state_text(state.get('last_error'))}") + print(f"Last wake: {_state_time(state.get('last_wake_at'))}") + print(f"Last success: {_state_time(state.get('last_success_at'))}") + print(f"Next wake: {_state_time(state.get('next_wake_at'))}") + print(f"Wake requested: {_state_time(state.get('wake_requested_at'))}") + print(f"Prompt: {_truncate_head(_single_line(meta.get('prompt') or ''), 160) or '-'}") + recent_runs = result.get("recent_runs") or [] + if not recent_runs: + return + print() + print("Recent runs:") + for run in recent_runs: + print(_format_managed_agent_run(run)) + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -218,6 +257,81 @@ def _truncate_tail(text, limit): return "..." + text[-(limit - 3) :] +def _repo_label(cwd): + if not isinstance(cwd, str) or not cwd: + return "-" + name = Path(cwd).name + return name or cwd + + +def _policy_label(stop_policy): + if stop_policy == "until_done": + return "done" + if stop_policy == "until_stopped": + return "loop" + return "-" + + +def _next_wake_label(item): + status = item.get("status") or "" + if status in ("done", "canceled"): + return "-" + if status == "paused": + return "paused" + if item.get("wake_requested_at"): + return "wake" + next_wake = _parse_timestamp(item.get("next_wake_at")) + if next_wake is None: + return "-" + now = datetime.now() + if next_wake <= now: + return "due" + return _short_duration((next_wake - now).total_seconds()) + + +def _short_duration(seconds): + if seconds <= 0: + return "due" + total = int(seconds) + days, rem = divmod(total, 86400) + if days: + return f"{days}d" + hours, rem = divmod(rem, 3600) + if hours: + return f"{hours}h" + minutes, secs = divmod(rem, 60) + if minutes: + return f"{minutes}m" + return f"{secs}s" + + +def _state_text(value): + text = _single_line(str(value or "")) + return text or "-" + + +def _state_time(value): + return value or "-" + + +def _format_managed_agent_run(run): + started = run.get("started_at") or "-" + reason = run.get("wake_reason") or "-" + usage = run.get("usage") or {} + tokens = _format_token_total(usage.get("total_tokens")) + status = run.get("error") or run.get("status") or "-" + reply = run.get("reply") or "" + message_count = len(run.get("messages") or []) + parts = [started, reason, tokens] + if message_count: + parts.append(f"msgs={message_count}") + summary = _truncate_head(_single_line(status), 60) + if reply: + summary = _truncate_head(f"{summary} | {_single_line(reply)}", 100) + parts.append(summary) + return "- " + " ".join(parts) + + def _parse_timestamp(value): if not isinstance(value, str): return None @@ -1629,7 +1743,7 @@ def main(argv=None): _print_managed_agent_list(list_managed_agents()) return if args.agent_command == "show": - print(json.dumps(show_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + _print_managed_agent_show(show_managed_agent(args.agent_ref)) return if args.agent_command == "read": if args.limit < 1: diff --git a/tests/test_agents.py b/tests/test_agents.py index 0354ea9..96ee9c2 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,9 +1,11 @@ +import io import json import os import sys import tempfile import unittest from contextlib import contextmanager +from contextlib import redirect_stdout from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -11,11 +13,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from codexapi.agents import ( + _codex_rollout_usage, _tick_lock_path, _try_lock, _remove_cron_line, _upsert_cron_line, control_agent, + format_utc, install_cron, nudge_agent, read_agent, @@ -27,6 +31,7 @@ uninstall_cron, write_tick_wrapper, ) +from codexapi.cli import _print_managed_agent_list, _print_managed_agent_show @contextmanager @@ -301,6 +306,217 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["reply"], "Message handled.") self.assertEqual(shown["unread_message_count"], 0) + def test_codex_rollout_usage_uses_latest_event_after_start(self): + started = datetime(2026, 3, 6, 8, 0, 5, tzinfo=timezone.utc) + with _temp_home() as home: + codex_home = home / "codex-home" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": "2026-03-06T08:00:01Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:06Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 20, + "output_tokens": 10, + "total_tokens": 30, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:07Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 12, + "total_tokens": 52, + } + }, + }, + }, + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + usage, path = _codex_rollout_usage( + {"rollout_path": str(rollout)}, + "thread-rollout", + started, + ) + self.assertEqual(path, str(rollout)) + self.assertEqual( + usage, + {"input_tokens": 40, "output_tokens": 12, "total_tokens": 52}, + ) + + def test_nudge_agent_reads_usage_from_codex_rollout(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + with _temp_home() as home: + codex_home = home / "codex-home" + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-rollout" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": format_utc(start + timedelta(seconds=5)), + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 10, + "total_tokens": 50, + } + }, + }, + } + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + return json.dumps( + { + "status": "Handled from rollout", + "continue": False, + "reply": "Used rollout tokens.", + } + ) + + agent = start_agent( + "Handle with real rollout accounting.", + hostname="host-a", + now=start, + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + with patch("codexapi.agents.Agent", FakeAgent): + with patch("codexapi.agents.utc_now", side_effect=[start, end]): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-rollout") + self.assertEqual(shown["state"]["input_tokens"], 40) + self.assertEqual(shown["state"]["output_tokens"], 10) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Used rollout tokens.") + self.assertIn("thread-rollout", shown["session"]["rollout_path"]) + + def test_cli_managed_agent_views_show_operator_fields(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + shown = show_agent(agent["id"]) + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("POL", list_out.getvalue()) + self.assertIn("REPO", list_out.getvalue()) + self.assertIn("done", list_out.getvalue()) + self.assertIn("codexapi", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("Policy: until_done", text) + self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text) + self.assertIn("Prompt: Handle messages.", text) + self.assertIn("Recent runs:", text) + self.assertIn("msgs=1", text) + if __name__ == "__main__": unittest.main() From fbde70672187807b2ce9b2ff409c10fa528df134 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 10:29:54 +0100 Subject: [PATCH 55/78] Add stable hostname override for agents --- docs/agent-v1.md | 23 +++++++++++++++++++++++ src/codexapi/agents.py | 9 +++++++-- tests/test_agents.py | 8 ++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 16026ce..1dedc2c 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -75,6 +75,28 @@ Why this exists: Two different `CODEXAPI_HOME` values are two different systems. They do not see each other's agents, locks, scheduler wrappers, or cron entries. +## `CODEXAPI_HOSTNAME` + +`CODEXAPI_HOSTNAME` overrides the host identity used for agent ownership and +host-specific locks. + +Default: + +- use the process hostname reported by the OS + +Override: + +```text +CODEXAPI_HOSTNAME=stable-hostname +``` + +Why this exists: + +- Some shells, cron environments, test harnesses, or sandboxes report different + hostnames for the same machine. +- Agent ownership depends on an exact hostname match. +- Tests and sandboxed runs need a stable explicit value. + ## Agent Model Each agent stores at least: @@ -364,6 +386,7 @@ Why it exists: The wrapper should: - export the resolved `CODEXAPI_HOME` +- export the resolved `CODEXAPI_HOSTNAME` - set a safe `PATH` - invoke the exact Python interpreter or installed `codexapi` path discovered at install time diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index cffb475..86b7d4a 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -57,6 +57,9 @@ def codexapi_home(): def current_hostname(): """Return the current hostname.""" + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if override: + return override name = socket.gethostname().strip() return name or "unknown-host" @@ -341,7 +344,7 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No _ensure_home(home) python_executable = python_executable or sys.executable path_value = path_value or os.environ.get("PATH", "") - wrapper = write_tick_wrapper(home, python_executable, path_value) + wrapper = write_tick_wrapper(home, python_executable, path_value, host) cron_line = render_cron_line(home, host) tag = _cron_tag(home, host) existing = _read_crontab() @@ -403,16 +406,18 @@ def resolve_agent_dir(agent_ref, home=None): return _agent_dir(home, matches[0]) -def write_tick_wrapper(home=None, python_executable=None, path_value=None): +def write_tick_wrapper(home=None, python_executable=None, path_value=None, hostname=None): """Write the cron wrapper script and return its path.""" home = _resolve_home(home) _ensure_home(home) python_executable = python_executable or sys.executable path_value = path_value or os.environ.get("PATH", "") + hostname = hostname or current_hostname() wrapper = home / "bin" / "agent-tick" lines = [ "#!/bin/bash", f"export CODEXAPI_HOME={shlex.quote(str(home))}", + f"export CODEXAPI_HOSTNAME={shlex.quote(str(hostname))}", f"export PATH={shlex.quote(path_value)}", f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", ] diff --git a/tests/test_agents.py b/tests/test_agents.py index 96ee9c2..ce2c422 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -42,6 +42,12 @@ def _temp_home(): class AgentsTests(unittest.TestCase): + def test_current_hostname_prefers_override(self): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + from codexapi.agents import current_hostname + + self.assertEqual(current_hostname(), "stable-host") + def test_homes_are_isolated(self): with _temp_home() as home_a: first = start_agent("Monitor the build queue.", hostname="host-a") @@ -155,10 +161,12 @@ def test_write_tick_wrapper_pins_home_and_python(self): home=home, python_executable="/tmp/venv/bin/python", path_value="/tmp/venv/bin:/usr/bin", + hostname="stable-host", ) text = wrapper.read_text(encoding="utf-8") self.assertIn("export CODEXAPI_HOME=", text) self.assertIn(str(home), text) + self.assertIn("export CODEXAPI_HOSTNAME=stable-host", text) self.assertIn("export PATH=", text) self.assertIn("/tmp/venv/bin:/usr/bin", text) self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) From cbbad614e90301f11752a9c282fa751367d7726a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 11:03:16 +0100 Subject: [PATCH 56/78] Improve agent orchestration and identity tooling --- docs/agent-v1.md | 17 ++++++- src/codexapi/agents.py | 95 +++++++++++++++++++++++++++++++++++-- src/codexapi/cli.py | 41 ++++++++++++++++ tests/test_agents.py | 104 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 250 insertions(+), 7 deletions(-) diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 1dedc2c..9bbb32a 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -105,6 +105,7 @@ Each agent stores at least: - `name`: human-readable unique name within the home - `created_at`: UTC timestamp - `created_by`: user name or parent agent name +- `parent_id`: parent agent id, if any - `hostname`: owning host for execution - `cwd`: working directory - `prompt`: original instruction text @@ -196,7 +197,7 @@ Why it exists: - Separates mostly-static configuration from rapidly changing state. Suggested contents: -- `id`, `name`, `created_at`, `created_by`, `hostname`, `cwd`, `prompt`, +- `id`, `name`, `created_at`, `created_by`, `parent_id`, `hostname`, `cwd`, `prompt`, `stop_policy`, `heartbeat_minutes` ### `agents//state.json` @@ -600,6 +601,18 @@ Why this matters: V1 should store only the minimum needed to recreate the expected environment. +Managed wakes should also expose stable agent identity to the backend process: + +- `CODEXAPI_AGENT_ID` +- `CODEXAPI_AGENT_NAME` +- `CODEXAPI_AGENT_PARENT_ID`, when relevant + +Why this matters: + +- a managed agent should be able to start another agent without manually + re-stating its own identity +- child agents should be able to record parentage automatically + ## Token Accounting V1 should not pretend to know dollar cost. @@ -627,6 +640,7 @@ V1 CLI surface: - `codexapi agent start` - `codexapi agent list` +- `codexapi agent whoami` - `codexapi agent read` - `codexapi agent show` - `codexapi agent send` @@ -641,6 +655,7 @@ Expected behavior: - `start` creates the agent directory, meta/state files, and host runtime files - `list` reads only this `CODEXAPI_HOME` +- `whoami` prints the effective host identity and `CODEXAPI_HOME` - `read` shows recent user-visible communication derived from state and run records - `show` reads one agent's current snapshot and recent run history diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 86b7d4a..95c4fbf 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -100,6 +100,7 @@ def start_agent( cwd=None, name=None, created_by=None, + parent_ref=None, stop_policy="until_done", heartbeat_minutes=5, backend=None, @@ -133,8 +134,9 @@ def start_agent( commands_claimed.mkdir(parents=True, exist_ok=False) runs_dir.mkdir(parents=True, exist_ok=False) + parent_id, parent_name = _parent_identity(home, parent_ref) if created_by is None: - created_by = os.environ.get("USER") or "user" + created_by = parent_name or os.environ.get("CODEXAPI_AGENT_NAME") or os.environ.get("USER") or "user" cwd = _resolve_cwd(cwd) session = { "thread_id": "", @@ -152,6 +154,7 @@ def start_agent( "name": agent_name, "created_at": format_utc(now), "created_by": str(created_by), + "parent_id": parent_id, "hostname": host, "cwd": cwd, "prompt": prompt.strip(), @@ -192,12 +195,13 @@ def list_agents(home=None): root = home / "agents" if not root.exists(): return [] + child_map = _child_map(home) agents = [] for agent_dir in root.iterdir(): if not agent_dir.is_dir(): continue try: - agents.append(_snapshot(agent_dir)) + agents.append(_snapshot(agent_dir, child_map)) except FileNotFoundError: continue agents.sort(key=lambda item: item["created_at"], reverse=True) @@ -206,13 +210,18 @@ def list_agents(home=None): def show_agent(agent_ref, home=None): """Return a full agent snapshot.""" + home = _resolve_home(home) + child_map = _child_map(home) agent_dir = resolve_agent_dir(agent_ref, home) - snapshot = _snapshot(agent_dir) + snapshot = _snapshot(agent_dir, child_map) snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) + snapshot["parent"] = _agent_brief(home, snapshot["parent_id"], child_map) + snapshot["children"] = _agent_briefs(home, snapshot["child_ids"], child_map) return snapshot @@ -569,7 +578,7 @@ def _run_agent_turn(meta, session, prompt, runner=None): session.get("flags") or None, include_thinking=False, backend=session.get("backend") or None, - env=session.get("env") or None, + env=_agent_env(meta, session), ) message = worker(prompt) usage = worker.last_usage or {} @@ -783,9 +792,13 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): return payload -def _snapshot(agent_dir): +def _snapshot(agent_dir, child_map=None): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + if child_map is None: + child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) + else: + child_ids = child_map.get(meta["id"], []) unread = int(state.get("unread_message_count") or 0) + len( _queued_send_commands(agent_dir) ) @@ -794,6 +807,7 @@ def _snapshot(agent_dir): "name": meta["name"], "created_at": meta["created_at"], "created_by": meta["created_by"], + "parent_id": meta.get("parent_id") or "", "hostname": meta["hostname"], "cwd": meta["cwd"], "stop_policy": meta["stop_policy"], @@ -809,6 +823,7 @@ def _snapshot(agent_dir): "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), "avg_tokens_per_hour": float(state.get("avg_tokens_per_hour") or 0.0), + "child_ids": list(child_ids), "last_error": state.get("last_error") or "", "activity": state.get("activity") or "", "reply": state.get("reply") or "", @@ -865,6 +880,18 @@ def _capture_env(): return env +def _parent_identity(home, parent_ref): + """Return the resolved parent agent id and name, if any.""" + if parent_ref is not None and str(parent_ref).strip(): + meta = _read_json(resolve_agent_dir(str(parent_ref), home) / "meta.json") + return meta["id"], meta["name"] + parent_id = os.environ.get("CODEXAPI_AGENT_ID", "").strip() + parent_name = os.environ.get("CODEXAPI_AGENT_NAME", "").strip() + if parent_id: + return parent_id, parent_name + return "", "" + + def _resolve_home(home): if home is None: return codexapi_home() @@ -917,6 +944,16 @@ def _sync_state_from_session(state, session): state["thread_id"] = session.get("thread_id") or "" +def _agent_env(meta, session): + """Return the backend env with stable agent identity added.""" + env = dict(session.get("env") or {}) + env["CODEXAPI_AGENT_ID"] = meta["id"] + env["CODEXAPI_AGENT_NAME"] = meta["name"] + if meta.get("parent_id"): + env["CODEXAPI_AGENT_PARENT_ID"] = meta["parent_id"] + return env + + def _command_id(now, hostname): stamp = now.strftime("%Y%m%dT%H%M%SZ") rand = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) @@ -1103,6 +1140,54 @@ def _queued_send_commands(agent_dir): return queued +def _child_map(home): + """Return parent_id -> [child ids] for this home.""" + root = _resolve_home(home) / "agents" + child_map = {} + if not root.exists(): + return child_map + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + meta = _read_json(agent_dir / "meta.json") + except FileNotFoundError: + continue + parent_id = meta.get("parent_id") or "" + if not parent_id: + continue + child_map.setdefault(parent_id, []).append(meta["id"]) + for child_ids in child_map.values(): + child_ids.sort() + return child_map + + +def _agent_brief(home, agent_id, child_map): + """Return a short snapshot for one related agent.""" + if not agent_id: + return None + try: + agent_dir = resolve_agent_dir(agent_id, home) + except ValueError: + return None + snapshot = _snapshot(agent_dir, child_map) + return { + "id": snapshot["id"], + "name": snapshot["name"], + "status": snapshot["status"], + "reply": snapshot["reply"], + } + + +def _agent_briefs(home, agent_ids, child_map): + """Return short snapshots for a list of related agents.""" + return [ + brief + for brief in (_agent_brief(home, agent_id, child_map) for agent_id in agent_ids or []) + if brief is not None + ] + + def _codex_rollout_usage(session, thread_id, started_at): """Return usage from the current Codex rollout plus its resolved path.""" if not thread_id: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 9623c82..3b8a0fc 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -14,7 +14,9 @@ from .agent import Agent, agent from .agents import ( + codexapi_home, control_agent, + current_hostname, install_cron as install_agent_cron, list_agents as list_managed_agents, nudge_agent, @@ -182,6 +184,13 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_identity(): + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + print(f"Host: {current_hostname()}") + print(f"Host override: {override or '-'}") + print(f"Home: {codexapi_home()}") + + def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] @@ -189,6 +198,8 @@ def _print_managed_agent_show(result): print(f"ID: {meta['id']}") print(f"Host: {meta['hostname']}") print(f"Created: {meta['created_at']} by {meta['created_by']}") + print(f"Parent: {_related_label(result.get('parent'))}") + print(f"Children: {_children_label(result.get('children'))}") print( f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" ) @@ -332,6 +343,24 @@ def _format_managed_agent_run(run): return "- " + " ".join(parts) +def _related_label(agent): + if not agent: + return "-" + ident = agent.get("id", "")[:8] + name = agent.get("name") or ident or "-" + status = agent.get("status") or "-" + return f"{name} [{status}] {ident}" + + +def _children_label(children): + if not children: + return "-" + labels = [_related_label(child) for child in children[:3]] + if len(children) > 3: + labels.append(f"+{len(children) - 3} more") + return ", ".join(labels) + + def _parse_timestamp(value): if not isinstance(value, str): return None @@ -1343,6 +1372,10 @@ def main(argv=None): "--created-by", help="Creator label (defaults to $USER).", ) + agent_start.add_argument( + "--parent", + help="Optional parent agent id, unique prefix, or name.", + ) agent_start.add_argument( "--stop-policy", default="until_done", @@ -1375,6 +1408,10 @@ def main(argv=None): "list", help="List durable agents in this CODEXAPI_HOME.", ) + agent_subparsers.add_parser( + "whoami", + help="Show the effective host and CODEXAPI_HOME for agents.", + ) agent_show = agent_subparsers.add_parser( "show", @@ -1731,6 +1768,7 @@ def main(argv=None): args.cwd, args.name, args.created_by, + args.parent, args.stop_policy, args.heartbeat_minutes, args.backend, @@ -1742,6 +1780,9 @@ def main(argv=None): if args.agent_command == "list": _print_managed_agent_list(list_managed_agents()) return + if args.agent_command == "whoami": + _print_managed_agent_identity() + return if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index ce2c422..99e6af1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -31,7 +31,11 @@ uninstall_cron, write_tick_wrapper, ) -from codexapi.cli import _print_managed_agent_list, _print_managed_agent_show +from codexapi.cli import ( + _print_managed_agent_identity, + _print_managed_agent_list, + _print_managed_agent_show, +) @contextmanager @@ -48,6 +52,17 @@ def test_current_hostname_prefers_override(self): self.assertEqual(current_hostname(), "stable-host") + def test_cli_whoami_shows_effective_host_and_home(self): + with _temp_home() as home: + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + _print_managed_agent_identity() + text = output.getvalue() + self.assertIn("Host: stable-host", text) + self.assertIn("Host override: stable-host", text) + self.assertIn(f"Home: {home.resolve()}", text) + def test_homes_are_isolated(self): with _temp_home() as home_a: first = start_agent("Monitor the build queue.", hostname="host-a") @@ -111,6 +126,93 @@ def fake_runner(meta, session, prompt): self.assertEqual(conversation["items"][1]["kind"], "agent") self.assertEqual(conversation["items"][1]["text"], "I saw your message.") + def test_start_agent_resolves_parent_ref(self): + with _temp_home(): + parent = start_agent( + "Parent work.", + name="parent-agent", + hostname="host-a", + ) + child = start_agent( + "Child work.", + name="child-agent", + parent_ref="parent-agent", + hostname="host-a", + ) + shown = show_agent(child["id"]) + self.assertEqual(shown["meta"]["parent_id"], parent["id"]) + self.assertEqual(shown["meta"]["created_by"], "parent-agent") + self.assertEqual(shown["parent"]["id"], parent["id"]) + + def test_managed_agent_can_create_child_with_parent_defaults(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + captured = {} + + with _temp_home(): + parent = start_agent( + "Spawn a child agent.", + name="parent-agent", + hostname="host-a", + now=start, + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + self.env = dict(env or {}) + captured["env"] = self.env + + def __call__(self, prompt): + with patch.dict(os.environ, self.env, clear=False): + child = start_agent( + "Child work.", + name="child-agent", + hostname="host-a", + ) + captured["child_id"] = child["id"] + return json.dumps( + { + "status": "Spawned child", + "continue": False, + "reply": child["id"], + } + ) + + with patch("codexapi.agents.Agent", FakeAgent): + with patch( + "codexapi.agents.utc_now", + side_effect=[start, start + timedelta(seconds=1), end], + ): + result = nudge_agent( + parent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_ID"], parent["id"]) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_NAME"], "parent-agent") + + child = show_agent(captured["child_id"]) + self.assertEqual(child["meta"]["created_by"], "parent-agent") + self.assertEqual(child["meta"]["parent_id"], parent["id"]) + self.assertEqual(child["parent"]["name"], "parent-agent") + + parent_view = show_agent(parent["id"]) + self.assertIn(captured["child_id"], parent_view["child_ids"]) + self.assertEqual(parent_view["children"][0]["name"], "child-agent") + def test_pause_then_resume(self): calls = [] From 7e45e5dd67f20f7a418d362f024ae805da331c59 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 09:54:27 +0100 Subject: [PATCH 57/78] Document durable agent usage in README --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e9472a..aa649a9 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,72 @@ If the leadbook does not exist, lead creates it with a template. ```bash codexapi lead 5 "Run the benchmark and wait for results." +codexapi lead 0 "Do a rapid triage pass and report." +``` + +### Durable agents + +`codexapi agent` is the durable long-running control plane. It stores state +under `CODEXAPI_HOME` (default `~/.codexapi`), wakes agents on cron, and lets +you inspect or message them from any shell that points at the same home. -Run without waiting between check-ins: +Start by checking the effective host/home pair and installing the scheduler: ```bash -codexapi lead 0 "Do a rapid triage pass and report." +codexapi agent whoami +codexapi agent install-cron +``` + +Start a goal-directed agent that decides for itself when it is done: + +```bash +codexapi agent start --name ci-fixer \ + "Watch CI, fix failing tests, open or update a PR, and stop when the work is done." +``` + +Start a persistent watcher that keeps running until you stop it: + +```bash +codexapi agent start --name issue-watcher \ + --stop-policy until_stopped \ + --heartbeat-minutes 30 \ + "Every wake, scan for newly assigned issues that look actionable and report or start follow-up work." ``` + +Inspect and talk to agents: + +```bash +codexapi agent list +codexapi agent show ci-fixer +codexapi agent read ci-fixer +codexapi agent send ci-fixer "Prefer the smallest safe fix." +codexapi agent wake ci-fixer +codexapi agent pause ci-fixer +codexapi agent resume ci-fixer +codexapi agent cancel ci-fixer +``` + +Create a child agent explicitly: + +```bash +codexapi agent start --name child-fix --parent ci-fixer \ + "Investigate the flaky integration test and report back." ``` +Useful environment overrides: + +```bash +CODEXAPI_HOME=/tmp/codexapi-test-home codexapi agent list +CODEXAPI_HOSTNAME=stable-host codexapi agent whoami +``` + +`CODEXAPI_HOME` isolates independent agent installations and is the right seam +for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test +wrappers report inconsistent hostnames for the same machine. + +See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling +details. + Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. From 5eef8011baa1012941e69cf331ef08e386aede4e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:18:42 +0100 Subject: [PATCH 58/78] Show immediate agent replies for send --- src/codexapi/cli.py | 25 +++++++++++++++++++++++++ tests/test_agents.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 3b8a0fc..79a8af1 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -191,6 +191,28 @@ def _print_managed_agent_identity(): print(f"Home: {codexapi_home()}") +def _send_reply_info(agent_ref, message_id): + """Return the matching run reply for one sent message, if already delivered.""" + shown = show_managed_agent(agent_ref) + for run in shown.get("recent_runs") or []: + for message in run.get("messages") or []: + if message.get("id") != message_id: + continue + info = { + "delivered": True, + "agent_status": run.get("status") or "", + "run_id": run.get("id") or "", + } + reply = run.get("reply") or "" + error = run.get("error") or "" + if reply: + info["agent_reply"] = reply + if error: + info["agent_error"] = error + return info + return None + + def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] @@ -1794,6 +1816,9 @@ def main(argv=None): if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["nudge"] = nudge_agent(args.agent_ref) + reply_info = _send_reply_info(args.agent_ref, result["id"]) + if reply_info: + result.update(reply_info) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("wake", "pause", "resume", "cancel"): diff --git a/tests/test_agents.py b/tests/test_agents.py index 99e6af1..ed1ee84 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -35,6 +35,7 @@ _print_managed_agent_identity, _print_managed_agent_list, _print_managed_agent_show, + main as cli_main, ) @@ -627,6 +628,48 @@ def fake_runner(meta, session, prompt): self.assertIn("Recent runs:", text) self.assertIn("msgs=1", text) + def test_cli_send_shows_immediate_agent_reply(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-cli-send" + return json.dumps( + { + "status": "Answered immediately", + "continue": False, + "reply": "I saw your note.", + } + ) + + output = io.StringIO() + with patch("codexapi.agents.Agent", FakeAgent): + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["nudge"]["woken"]) + self.assertTrue(payload["delivered"]) + self.assertEqual(payload["agent_status"], "Answered immediately") + self.assertEqual(payload["agent_reply"], "I saw your note.") + if __name__ == "__main__": unittest.main() From 0da89b6391088f79fd24b0f4b71c4daad2c8704a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:20:50 +0100 Subject: [PATCH 59/78] Add agentbook inspection commands --- README.md | 4 ++++ docs/agent-v1.md | 2 ++ src/codexapi/agents.py | 12 ++++++++++++ src/codexapi/cli.py | 22 ++++++++++++++++++++++ tests/test_agents.py | 16 ++++++++++++++++ 5 files changed, 56 insertions(+) diff --git a/README.md b/README.md index aa649a9..2b57008 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Inspect and talk to agents: codexapi agent list codexapi agent show ci-fixer codexapi agent read ci-fixer +codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." codexapi agent wake ci-fixer codexapi agent pause ci-fixer @@ -204,6 +205,9 @@ CODEXAPI_HOSTNAME=stable-host codexapi agent whoami for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test wrappers report inconsistent hostnames for the same machine. +`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can +jump directly to the durable working memory file. + See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling details. diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 9bbb32a..f9b7ab0 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -642,6 +642,7 @@ V1 CLI surface: - `codexapi agent list` - `codexapi agent whoami` - `codexapi agent read` +- `codexapi agent book` - `codexapi agent show` - `codexapi agent send` - `codexapi agent wake` @@ -658,6 +659,7 @@ Expected behavior: - `whoami` prints the effective host identity and `CODEXAPI_HOME` - `read` shows recent user-visible communication derived from state and run records +- `book` prints the current `AGENTBOOK.md` text for one agent - `show` reads one agent's current snapshot and recent run history - `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files - `tick` processes due agents for the current hostname only diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 95c4fbf..3e9a6ba 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -214,6 +214,7 @@ def show_agent(agent_ref, home=None): child_map = _child_map(home) agent_dir = resolve_agent_dir(agent_ref, home) snapshot = _snapshot(agent_dir, child_map) + snapshot["agentbook_path"] = str(agent_dir / "AGENTBOOK.md") snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] @@ -284,6 +285,17 @@ def read_agent(agent_ref, limit=10, home=None): } +def read_agentbook(agent_ref, home=None): + """Return the current agentbook path and text for one agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + path = agent_dir / "AGENTBOOK.md" + return { + "id": _read_json(agent_dir / "meta.json")["id"], + "path": str(path), + "text": _read_text(path), + } + + def send_agent(agent_ref, message, author=None, home=None, hostname=None, now=None): """Queue a message for an agent.""" if not isinstance(message, str) or not message.strip(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 79a8af1..b98f63b 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -21,6 +21,7 @@ list_agents as list_managed_agents, nudge_agent, read_agent as read_managed_agent, + read_agentbook, send_agent, show_agent as show_managed_agent, start_agent as start_managed_agent, @@ -226,6 +227,7 @@ def _print_managed_agent_show(result): f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" ) print(f"CWD: {meta['cwd']}") + print(f"Agentbook: {result.get('agentbook_path') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") print( "Tokens: " @@ -251,6 +253,17 @@ def _print_managed_agent_show(result): print(_format_managed_agent_run(run)) +def _print_managed_agent_book(result): + print(f"Agentbook: {result['path']}") + text = result.get("text") or "" + if text: + print() + print(text, end="" if text.endswith("\n") else "\n") + return + print() + print("(empty)") + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -1453,6 +1466,12 @@ def main(argv=None): help="Maximum number of items to show (default: 10).", ) + agent_book = agent_subparsers.add_parser( + "book", + help="Show the current agentbook for one agent.", + ) + agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_send = agent_subparsers.add_parser( "send", help="Queue a message for an agent.", @@ -1813,6 +1832,9 @@ def main(argv=None): raise SystemExit("--limit must be >= 1.") _print_managed_agent_read(read_managed_agent(args.agent_ref, args.limit)) return + if args.agent_command == "book": + _print_managed_agent_book(read_agentbook(args.agent_ref)) + return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["nudge"] = nudge_agent(args.agent_ref) diff --git a/tests/test_agents.py b/tests/test_agents.py index ed1ee84..0e47630 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -23,6 +23,7 @@ install_cron, nudge_agent, read_agent, + read_agentbook, render_cron_line, send_agent, show_agent, @@ -70,12 +71,27 @@ def test_homes_are_isolated(self): self.assertEqual(first["name"], "monitor-the-build-queue") agents_a = show_agent(first["id"]) self.assertEqual(agents_a["meta"]["hostname"], "host-a") + self.assertTrue(agents_a["agentbook_path"].endswith("/AGENTBOOK.md")) with _temp_home() as home_b: with self.assertRaises(ValueError): show_agent(first["id"]) second = start_agent("Watch CI failures.", hostname="host-b") self.assertNotEqual(first["id"], second["id"]) + def test_read_agentbook_and_cli_book(self): + with _temp_home(): + agent = start_agent("Keep notes.", hostname="host-a") + book = read_agentbook(agent["id"]) + self.assertTrue(book["path"].endswith("/AGENTBOOK.md")) + self.assertIn("# Agentbook", book["text"]) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "book", agent["id"]]) + text = output.getvalue() + self.assertIn("Agentbook:", text) + self.assertIn("# Agentbook", text) + def test_cross_host_message_waits_for_owner_tick(self): prompts = [] From 3e8d19cbc20d69f1f91c62b23f3a846872c545fd Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:27:41 +0100 Subject: [PATCH 60/78] Add agent deletion and version flag --- README.md | 2 ++ docs/agent-v1.md | 2 ++ src/codexapi/agents.py | 36 +++++++++++++++++++++ src/codexapi/cli.py | 27 ++++++++++++++++ tests/test_agents.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+) diff --git a/README.md b/README.md index 2b57008..c334a84 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Cursor agent backend. After installing, use the `codexapi` command: ```bash +codexapi --version codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run @@ -185,6 +186,7 @@ codexapi agent wake ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent cancel ci-fixer +codexapi agent delete ci-fixer ``` Create a child agent explicitly: diff --git a/docs/agent-v1.md b/docs/agent-v1.md index f9b7ab0..6dbdf0a 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -649,6 +649,7 @@ V1 CLI surface: - `codexapi agent pause` - `codexapi agent resume` - `codexapi agent cancel` +- `codexapi agent delete` - `codexapi agent tick` - `codexapi agent install-cron` @@ -662,6 +663,7 @@ Expected behavior: - `book` prints the current `AGENTBOOK.md` text for one agent - `show` reads one agent's current snapshot and recent run history - `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files +- `delete` removes one agent directory when it is safe to do so - `tick` processes due agents for the current hostname only - `install-cron` installs exactly one scheduler entry for this home on this host diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 3e9a6ba..c90ecfd 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -4,6 +4,7 @@ import os import random import shlex +import shutil import socket import string import subprocess @@ -318,6 +319,35 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def delete_agent(agent_ref, force=False, home=None): + """Delete one agent directory when it is safe to do so.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + meta = _read_json(agent_dir / "meta.json") + status = snapshot["status"] + if _run_lock_held(agent_dir / "hosts" / meta["hostname"] / "run.lock"): + raise ValueError("Cannot delete an agent while its run lock is held.") + if not force and status not in _TERMINAL_STATES: + raise ValueError( + "Refusing to delete a non-terminal agent. Cancel it first or use --force." + ) + if not force and snapshot["child_ids"]: + raise ValueError( + "Refusing to delete an agent that still has child agents. Use --force if you really want to remove it." + ) + shutil.rmtree(agent_dir) + return { + "deleted": True, + "id": snapshot["id"], + "name": snapshot["name"], + "status": status, + "path": str(agent_dir), + "forced": bool(force), + } + + def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): """Attempt an immediate wake for one locally-owned agent.""" agent_dir = resolve_agent_dir(agent_ref, home) @@ -945,6 +975,12 @@ def _write_lock_info(handle, hostname, now): handle.flush() +def _run_lock_held(path): + """Return true when the per-agent run lock is currently held.""" + with _try_lock(path) as handle: + return handle is None + + def _read_session(agent_dir): meta = _read_json(agent_dir / "meta.json") return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index b98f63b..4cb5962 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -12,11 +12,13 @@ from datetime import datetime from pathlib import Path +from . import __version__ from .agent import Agent, agent from .agents import ( codexapi_home, control_agent, current_hostname, + delete_agent as delete_managed_agent, install_cron as install_agent_cron, list_agents as list_managed_agents, nudge_agent, @@ -1299,6 +1301,11 @@ def main(argv=None): prog="codexapi", description="Run agent backends via the codexapi wrapper.", ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) subparsers = parser.add_subparsers(dest="command") run_parser = subparsers.add_parser( @@ -1490,6 +1497,17 @@ def main(argv=None): subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") + agent_delete = agent_subparsers.add_parser( + "delete", + help="Delete one durable agent and its files.", + ) + agent_delete.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_delete.add_argument( + "--force", + action="store_true", + help="Delete even when the agent is not terminal or still has children.", + ) + agent_subparsers.add_parser( "tick", help="Process due agents for the current host.", @@ -1852,6 +1870,15 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "delete": + print( + json.dumps( + delete_managed_agent(args.agent_ref, args.force), + indent=2, + sort_keys=True, + ) + ) + return if args.agent_command == "tick": print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 0e47630..5036136 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from codexapi import __version__ from codexapi.agents import ( _codex_rollout_usage, _tick_lock_path, @@ -19,6 +20,7 @@ _remove_cron_line, _upsert_cron_line, control_agent, + delete_agent, format_utc, install_cron, nudge_agent, @@ -48,6 +50,14 @@ def _temp_home(): class AgentsTests(unittest.TestCase): + def test_cli_version(self): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(["--version"]) + self.assertEqual(exc.exception.code, 0) + self.assertEqual(output.getvalue().strip(), f"codexapi {__version__}") + def test_current_hostname_prefers_override(self): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): from codexapi.agents import current_hostname @@ -92,6 +102,69 @@ def test_read_agentbook_and_cli_book(self): self.assertIn("Agentbook:", text) self.assertIn("# Agentbook", text) + def test_delete_agent_removes_done_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + result = delete_agent(agent["id"]) + self.assertTrue(result["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + + def test_delete_agent_refuses_non_terminal_without_force(self): + with _temp_home(): + agent = start_agent("Do not delete me yet.", hostname="host-a") + with self.assertRaises(ValueError): + delete_agent(agent["id"]) + result = delete_agent(agent["id"], force=True) + self.assertTrue(result["forced"]) + + def test_delete_agent_refuses_when_run_lock_held(self): + with _temp_home() as home: + agent = start_agent("Locked agent.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + lock_path = agent_dir / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + with self.assertRaises(ValueError): + delete_agent(agent["id"], force=True) + + def test_cli_delete_removes_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete-cli", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "delete", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + def test_cross_host_message_waits_for_owner_tick(self): prompts = [] From 59b405110799143a6a11e1c3fefcd1841a0114e7 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:27:59 +0100 Subject: [PATCH 61/78] Bump version to 0.9.0 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e82a901..664fbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.8.0" +version = "0.9.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 17c694a..0460f0f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.8.0" +__version__ = "0.9.0" From bdd4f74e0f7c73bb469976ed129382a07d430613 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 12:09:18 +0100 Subject: [PATCH 62/78] Release v0.10.0 --- README.md | 12 ++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 37 +++++++++-- src/codexapi/cli.py | 87 +++++++++++++++++++++++--- tests/test_agents.py | 130 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 253 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c334a84..8f6c948 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,12 @@ codexapi agent whoami codexapi agent install-cron ``` +If you skip `install-cron`, `codexapi agent start` warns on stderr because +background wakes will not run until the scheduler hook is installed. +When `gh` is installed and authenticated, `agent start` also captures a +background-safe `GH_TOKEN` automatically if your shell did not already export +`GH_TOKEN` or `GITHUB_TOKEN`. + Start a goal-directed agent that decides for itself when it is done: ```bash @@ -165,6 +171,9 @@ codexapi agent start --name ci-fixer \ "Watch CI, fix failing tests, open or update a PR, and stop when the work is done." ``` +Add `--wait` if you want `start` to block for the first local wake instead of +just scheduling it. + Start a persistent watcher that keeps running until you stop it: ```bash @@ -182,9 +191,12 @@ codexapi agent show ci-fixer codexapi agent read ci-fixer codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." +codexapi agent send --wait ci-fixer "Reply now if you can handle this immediately." codexapi agent wake ci-fixer +codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer +codexapi agent resume --wait ci-fixer codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` diff --git a/pyproject.toml b/pyproject.toml index 664fbc5..131bd49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.9.0" +version = "0.10.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0460f0f..b7458d2 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index c90ecfd..412b3c8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -412,6 +412,17 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } +def cron_installed(home=None, hostname=None): + """Return whether this home and host have an installed scheduler hook.""" + home = _resolve_home(home) + host = hostname or current_hostname() + tag = _cron_tag(home, host) + wrapper = home / "bin" / "agent-tick" + crontab = _read_crontab() + installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) + return installed and wrapper.exists() + + def uninstall_cron(home=None, hostname=None): """Remove the cron entry for this home and host.""" home = _resolve_home(home) @@ -915,13 +926,31 @@ def _resolve_cwd(cwd): def _capture_env(): env = {} - for key in ("PATH", "VIRTUAL_ENV"): - value = os.environ.get(key) - if value: - env[key] = value + for key, value in os.environ.items(): + if key in ("CODEXAPI_AGENT_ID", "CODEXAPI_AGENT_NAME", "CODEXAPI_AGENT_PARENT_ID"): + continue + env[key] = value + if not (env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")): + gh_token = _gh_auth_token() + if gh_token: + env["GH_TOKEN"] = gh_token return env +def _gh_auth_token(): + """Return the active gh auth token when available.""" + if shutil.which("gh") is None: + return "" + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return "" + return (result.stdout or "").strip() + + def _parent_identity(home, parent_ref): """Return the resolved parent agent id and name, if any.""" if parent_ref is not None and str(parent_ref).strip(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 4cb5962..feafe00 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -3,6 +3,7 @@ import os import re import select +import shlex import shutil import subprocess import sys @@ -17,6 +18,7 @@ from .agents import ( codexapi_home, control_agent, + cron_installed as agent_cron_installed, current_hostname, delete_agent as delete_managed_agent, install_cron as install_agent_cron, @@ -194,6 +196,39 @@ def _print_managed_agent_identity(): print(f"Home: {codexapi_home()}") +def _agent_install_cron_command(): + parts = [] + home = os.environ.get("CODEXAPI_HOME", "").strip() + host = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if home: + parts.append(f"CODEXAPI_HOME={shlex.quote(home)}") + if host: + parts.append(f"CODEXAPI_HOSTNAME={shlex.quote(host)}") + parts.extend(["codexapi", "agent", "install-cron"]) + return " ".join(parts) + + +def _warn_agent_scheduler_missing(): + try: + installed = agent_cron_installed() + except Exception as exc: + print( + "Warning: could not verify whether the codexapi agent scheduler hook is installed.", + file=sys.stderr, + ) + print(f"Reason: {exc}", file=sys.stderr) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + return + if installed: + return + print( + "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. " + "Background agent wakes will not run until you install it.", + file=sys.stderr, + ) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + + def _send_reply_info(agent_ref, message_id): """Return the matching run reply for one sent message, if already delivered.""" shown = show_managed_agent(agent_ref) @@ -1401,7 +1436,7 @@ def main(argv=None): agent_start = agent_subparsers.add_parser( "start", - help="Create a durable agent.", + help="Create a durable agent and return immediately unless --wait is set.", ) agent_start.add_argument( "prompt", @@ -1445,6 +1480,11 @@ def main(argv=None): "--flags", help="Additional raw CLI flags to pass to the backend.", ) + agent_start.add_argument( + "--wait", + action="store_true", + help="Wait for the first local wake to finish instead of just scheduling it.", + ) agent_subparsers.add_parser( "list", @@ -1481,21 +1521,32 @@ def main(argv=None): agent_send = agent_subparsers.add_parser( "send", - help="Queue a message for an agent.", + help="Queue a message for an agent and return immediately unless --wait is set.", ) agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_send.add_argument("message", help="Message to queue.") agent_send.add_argument("--author", help="Author label for the message.") + agent_send.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the message.", + ) for subcommand, help_text in ( - ("wake", "Request an extra wake for an agent."), + ("wake", "Request an extra wake for an agent and return immediately unless --wait is set."), ("pause", "Pause an agent."), - ("resume", "Resume a paused agent."), + ("resume", "Resume a paused agent and return immediately unless --wait is set."), ("cancel", "Cancel an agent."), ): subparser = agent_subparsers.add_parser(subcommand, help=help_text) subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") + if subcommand in ("wake", "resume"): + subparser.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the command.", + ) agent_delete = agent_subparsers.add_parser( "delete", @@ -1834,6 +1885,10 @@ def main(argv=None): args.yolo, args.flags, ) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(result["id"]) + _warn_agent_scheduler_missing() print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "list": @@ -1855,18 +1910,32 @@ def main(argv=None): return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) - result["nudge"] = nudge_agent(args.agent_ref) - reply_info = _send_reply_info(args.agent_ref, result["id"]) - if reply_info: - result.update(reply_info) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref) + reply_info = _send_reply_info(args.agent_ref, result["id"]) + if reply_info: + result.update(reply_info) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("wake", "resume"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return - if args.agent_command in ("wake", "pause", "resume", "cancel"): + if args.agent_command in ("pause", "cancel"): result = control_agent( args.agent_ref, args.agent_command, args.author, ) + result["waited"] = False result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 5036136..58dadde 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,10 +1,12 @@ import io import json import os +import subprocess import sys import tempfile import unittest from contextlib import contextmanager +from contextlib import redirect_stderr from contextlib import redirect_stdout from datetime import datetime, timedelta, timezone from pathlib import Path @@ -663,6 +665,98 @@ def __call__(self, prompt): self.assertEqual(shown["state"]["reply"], "Used rollout tokens.") self.assertIn("thread-rollout", shown["session"]["rollout_path"]) + def test_start_agent_replays_start_time_env_on_later_wakes(self): + captured = {} + + with _temp_home(): + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "expected-value", + "GH_TOKEN": "ghp-test-token", + }, + clear=False, + ): + agent = start_agent( + "Use my saved environment.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + captured["env"] = dict(env or {}) + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + return json.dumps( + { + "status": "Handled with saved env", + "continue": False, + "reply": "done", + } + ) + + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "different-value", + "GH_TOKEN": "", + }, + clear=False, + ): + with patch("codexapi.agents.Agent", FakeAgent): + nudge_agent(agent["id"], hostname="host-a") + + self.assertEqual(captured["env"]["CUSTOM_AGENT_ENV"], "expected-value") + self.assertEqual(captured["env"]["GH_TOKEN"], "ghp-test-token") + + def test_start_agent_captures_gh_token_when_env_is_missing(self): + with _temp_home(): + with patch.dict( + os.environ, + {"GH_TOKEN": "", "GITHUB_TOKEN": ""}, + clear=False, + ): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["gh", "auth", "token"], + 0, + stdout="gho-from-gh-auth\n", + stderr="", + ), + ): + agent = start_agent( + "Use GitHub from cron.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "gho-from-gh-auth") + + def test_start_agent_keeps_existing_gh_token_without_calling_gh(self): + with _temp_home(): + with patch.dict(os.environ, {"GH_TOKEN": "existing-gh-token"}, clear=False): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch("codexapi.agents.subprocess.run") as run_mock: + agent = start_agent( + "Use existing GitHub token.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "existing-gh-token") + run_mock.assert_not_called() + def test_cli_managed_agent_views_show_operator_fields(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(hours=1) @@ -717,7 +811,38 @@ def fake_runner(meta, session, prompt): self.assertIn("Recent runs:", text) self.assertIn("msgs=1", text) - def test_cli_send_shows_immediate_agent_reply(self): + def test_cli_start_warns_when_cron_missing(self): + with _temp_home() as home: + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.cli.agent_cron_installed", return_value=False): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("Background agent wakes will not run", warning) + self.assertIn(str(home), warning) + self.assertIn("codexapi agent install-cron", warning) + + def test_cli_send_queues_by_default(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertNotIn("nudge", payload) + shown = show_agent(agent["id"]) + self.assertEqual(shown["unread_message_count"], 1) + self.assertEqual(shown["state"]["status"], "ready") + + def test_cli_send_wait_shows_immediate_agent_reply(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): agent = start_agent( @@ -752,8 +877,9 @@ def __call__(self, prompt): output = io.StringIO() with patch("codexapi.agents.Agent", FakeAgent): with redirect_stdout(output): - cli_main(["agent", "send", agent["id"], "status"]) + cli_main(["agent", "send", "--wait", agent["id"], "status"]) payload = json.loads(output.getvalue()) + self.assertTrue(payload["waited"]) self.assertTrue(payload["nudge"]["woken"]) self.assertTrue(payload["delivered"]) self.assertEqual(payload["agent_status"], "Answered immediately") From 9f7a2ff1c465738a921e120e41358ea6101c7bcb Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 12:57:29 +0100 Subject: [PATCH 63/78] Release v0.10.1 --- README.md | 1 + pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 45 ++++++++++++++++++++ src/codexapi/cli.py | 29 +++++++++++++ tests/test_agents.py | 90 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f6c948..96592a2 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent resume --wait ci-fixer +codexapi agent set-heartbeat ci-fixer 30 codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` diff --git a/pyproject.toml b/pyproject.toml index 131bd49..50a40e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.10.0" +version = "0.10.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b7458d2..641e09a 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.10.0" +__version__ = "0.10.1" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 412b3c8..425dcfd 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -319,6 +319,51 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None): + """Update an agent heartbeat interval.""" + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + home = _resolve_home(home) + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta_path = agent_dir / "meta.json" + state_path = agent_dir / "state.json" + meta = _read_json(meta_path) + state = _read_json(state_path) + new_minutes = int(heartbeat_minutes) + old_minutes = int(meta.get("heartbeat_minutes") or 0) + changed = old_minutes != new_minutes + if changed: + meta["heartbeat_minutes"] = new_minutes + _write_json(meta_path, meta) + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + running = _run_lock_held(run_lock_path) + rescheduled = False + if ( + not running + and state.get("status") in ("ready", "error") + and not state.get("wake_requested_at") + and state.get("next_wake_at") + ): + state["next_wake_at"] = format_utc( + now + timedelta(minutes=new_minutes) + ) + _write_json(state_path, state) + rescheduled = True + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "old_heartbeat_minutes": old_minutes, + "heartbeat_minutes": new_minutes, + "changed": changed, + "running": running, + "rescheduled": rescheduled, + "applies_after_current_run": bool(running), + "next_wake_at": state.get("next_wake_at") or "", + } + + def delete_agent(agent_ref, force=False, home=None): """Delete one agent directory when it is safe to do so.""" home = _resolve_home(home) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index feafe00..3078cd2 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -27,6 +27,7 @@ read_agent as read_managed_agent, read_agentbook, send_agent, + set_agent_heartbeat, show_agent as show_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, @@ -1548,6 +1549,20 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) + agent_set_heartbeat = agent_subparsers.add_parser( + "set-heartbeat", + help="Update the heartbeat interval for one durable agent.", + ) + agent_set_heartbeat.add_argument( + "agent_ref", + help="Agent id, unique prefix, or name.", + ) + agent_set_heartbeat.add_argument( + "heartbeat_minutes", + type=int, + help="Heartbeat interval in minutes.", + ) + agent_delete = agent_subparsers.add_parser( "delete", help="Delete one durable agent and its files.", @@ -1939,6 +1954,20 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "set-heartbeat": + if args.heartbeat_minutes < 0: + raise SystemExit("heartbeat_minutes must be >= 0.") + print( + json.dumps( + set_agent_heartbeat( + args.agent_ref, + args.heartbeat_minutes, + ), + indent=2, + sort_keys=True, + ) + ) + return if args.agent_command == "delete": print( json.dumps( diff --git a/tests/test_agents.py b/tests/test_agents.py index 58dadde..726f7d5 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -30,6 +30,7 @@ read_agentbook, render_cron_line, send_agent, + set_agent_heartbeat, show_agent, start_agent, tick, @@ -338,6 +339,74 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["status"], "ready") self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + def test_set_agent_heartbeat_updates_meta_and_reschedules_idle_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=1) + now = start + timedelta(minutes=2) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Still watching.", + } + ), + "thread_id": "thread-heartbeat", + } + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start, + runner=fake_runner, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertTrue(result["changed"]) + self.assertTrue(result["rescheduled"]) + self.assertFalse(result["running"]) + self.assertEqual(result["heartbeat_minutes"], 10) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 10) + self.assertEqual( + shown["state"]["next_wake_at"], + format_utc(now + timedelta(minutes=10)), + ) + + def test_set_agent_heartbeat_leaves_pending_wake_time_when_wake_requested(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + now = start + timedelta(minutes=1) + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertFalse(result["rescheduled"]) + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(start)) + self.assertEqual(shown["state"]["next_wake_at"], format_utc(start)) + def test_tick_lock_is_non_blocking(self): with _temp_home() as home: start_agent("Do the thing.", hostname="host-a") @@ -885,6 +954,27 @@ def __call__(self, prompt): self.assertEqual(payload["agent_status"], "Answered immediately") self.assertEqual(payload["agent_reply"], "I saw your note.") + def test_cli_set_heartbeat_updates_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + later = start + timedelta(minutes=3) + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + output = io.StringIO() + with patch("codexapi.agents.utc_now", return_value=later): + with redirect_stdout(output): + cli_main(["agent", "set-heartbeat", agent["id"], "12"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["changed"]) + self.assertEqual(payload["heartbeat_minutes"], 12) + shown = show_agent(agent["id"]) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + if __name__ == "__main__": unittest.main() From deac7e5c8d7dbb51f17677d0a9d8a46cc4257896 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 16:48:43 +0100 Subject: [PATCH 64/78] Add agent status command --- README.md | 10 + docs/agent-v1.md | 6 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 339 +++++++++++++++++++++++++- src/codexapi/cli.py | 62 +++++ tests/test_agents.py | 497 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 901 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 96592a2..ad9e4b1 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ Inspect and talk to agents: ```bash codexapi agent list codexapi agent show ci-fixer +codexapi agent status ci-fixer +codexapi agent status --actions ci-fixer codexapi agent read ci-fixer codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." @@ -202,6 +204,10 @@ codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` +`codexapi agent resume` can reopen a `done` agent. Sending to a `done` or +`canceled` agent still triggers a one-off wake on the next tick so you can get +a reply without putting the agent back into continuous heartbeat mode. + Create a child agent explicitly: ```bash @@ -222,6 +228,10 @@ wrappers report inconsistent hostnames for the same machine. `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can jump directly to the durable working memory file. +`codexapi agent status` reads the latest turn from the agent's rollout log and +shows recent commentary plus the final visible output. Pass `--actions` to +include the tool-action summary. If a wake is still in progress, it shows the +active turn so far. See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling details. diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 6dbdf0a..50740d2 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -547,9 +547,10 @@ Processing rules: - commands are applied in timestamp order - `pause` and `cancel` are applied before starting a new backend wake - `send` contributes to the next prompt and increments unread counts until - consumed + consumed; `done` and `canceled` agents still process queued messages as a + one-off wake - `wake` means run soon even if no heartbeat is due -- `resume` only changes state when the agent is paused +- `resume` reopens a `paused` or `done` agent - after successful application, the owner host records the result in state or a run record and deletes the claimed file @@ -644,6 +645,7 @@ V1 CLI surface: - `codexapi agent read` - `codexapi agent book` - `codexapi agent show` +- `codexapi agent status` - `codexapi agent send` - `codexapi agent wake` - `codexapi agent pause` diff --git a/pyproject.toml b/pyproject.toml index 50a40e3..8e25bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.10.1" +version = "0.11.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 641e09a..54c8a7e 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.10.1" +__version__ = "0.11.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 425dcfd..247ccc0 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -227,6 +227,48 @@ def show_agent(agent_ref, home=None): return snapshot +def status_agent(agent_ref, home=None, include_actions=False): + """Return detailed transcript status for the latest agent turn.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + session = _read_session(agent_dir) + rollout_path = _resolve_rollout_path( + session.get("rollout_path"), + session.get("thread_id") or snapshot.get("thread_id") or "", + ) + result = { + "id": snapshot["id"], + "name": snapshot["name"], + "agent_status": snapshot["status"], + "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "", + "rollout_path": str(rollout_path) if rollout_path else "", + "turn_id": "", + "turn_state": "missing", + "started_at": "", + "ended_at": "", + "cwd": snapshot.get("cwd") or "", + "progress": [], + "tools": [], + "final_output": "", + "final_json": None, + } + if rollout_path is None or not rollout_path.exists(): + return result + events = _rollout_events(rollout_path) + turn = _last_rollout_turn(events, include_actions) + if turn is None: + return result + run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock" + turn_state = "complete" + if not turn["ended_at"]: + turn_state = "active" if _run_lock_held(run_lock_path) else "interrupted" + result.update(turn) + result["turn_state"] = turn_state + return result + + def read_agent(agent_ref, limit=10, home=None): """Return recent user-visible communication for an agent.""" agent_dir = resolve_agent_dir(agent_ref, home) @@ -567,15 +609,16 @@ def _tick_agent(agent_dir, now, runner): _sync_state_from_session(state, session) _write_json(session_path, session) _write_json(agent_dir / "state.json", state) - if state.get("status") not in ("ready", "error"): + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: return {"processed": bool(commands), "woken": False} if not _is_due(state, now): return {"processed": bool(commands), "woken": False} - _wake_agent(agent_dir, meta, state, session, now, commands, runner) + _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status) return {"processed": True, "woken": True} -def _wake_agent(agent_dir, meta, state, session, now, commands, runner): +def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""): prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) state["status"] = "running" state["last_wake_at"] = format_utc(now) @@ -623,7 +666,10 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): state["thread_id"] = session["thread_id"] state["wake_requested_at"] = "" state["activity"] = response["status"] - if response["continue"]: + if terminal_status: + state["status"] = terminal_status + state["next_wake_at"] = "" + elif response["continue"]: state["status"] = "ready" state["next_wake_at"] = format_utc( ended + timedelta(minutes=meta["heartbeat_minutes"]) @@ -647,13 +693,16 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): Pushover().send(title, response["notify"]) except Exception as exc: ended = utc_now() - state["status"] = "error" + state["status"] = terminal_status or "error" state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__ state["activity"] = state["last_error"] state["wake_requested_at"] = "" - state["next_wake_at"] = format_utc( - ended + timedelta(minutes=meta["heartbeat_minutes"]) - ) + if terminal_status: + state["next_wake_at"] = "" + else: + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) _sync_state_from_session(state, session) _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) _write_json(agent_dir / "state.json", state) @@ -812,11 +861,11 @@ def _apply_commands(meta, state, session, commands, now): state["activity"] = "Paused" changed = True elif kind == "resume": - if state.get("status") == "paused": + if state.get("status") in ("paused", "done"): state["status"] = "ready" - state["wake_requested_at"] = format_utc(now) - state["activity"] = "Resumed" - changed = True + state["wake_requested_at"] = format_utc(now) + state["activity"] = "Resumed" + changed = True elif kind == "cancel": state["status"] = "canceled" state["activity"] = "Canceled" @@ -837,6 +886,8 @@ def _apply_commands(meta, state, session, commands, now): def _is_due(state, now): status = state.get("status") + if status in ("done", "canceled") and int(state.get("unread_message_count") or 0) > 0: + return True if status not in ("ready", "error"): return False if state.get("wake_requested_at"): @@ -849,6 +900,16 @@ def _is_due(state, now): return False +def _one_shot_terminal_status(state): + """Return the terminal status when a one-off message wake should run.""" + status = state.get("status") or "" + if status not in _TERMINAL_STATES: + return "" + if int(state.get("unread_message_count") or 0) < 1: + return "" + return status + + def _write_run(agent_dir, hostname, payload): runs_dir = agent_dir / "hosts" / hostname / "runs" filename = f"{payload['id']}.json" @@ -1327,8 +1388,10 @@ def _resolve_rollout_path(known_path, thread_id): """Return the rollout file for a thread, preferring the cached session path.""" if known_path: path = Path(known_path) - if path.exists() and thread_id in path.name: + if path.exists() and (not thread_id or thread_id in path.name): return path + if not thread_id: + return None root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions" if not root.exists(): return None @@ -1380,6 +1443,256 @@ def _extract_rollout_usage(path, started_at): return latest +def _rollout_events(path): + """Return parsed JSONL events from one rollout file.""" + events = [] + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + except OSError: + return [] + return events + + +def _last_rollout_turn(events, include_actions=False): + """Return the latest task_started slice from rollout events.""" + turn_events = [] + for event in events: + payload = event.get("payload") or {} + if event.get("type") == "event_msg" and payload.get("type") == "task_started": + turn_events = [event] + continue + if turn_events: + turn_events.append(event) + if not turn_events: + return None + + started = turn_events[0] + started_payload = started.get("payload") or {} + progress_events = [] + assistant_events = [] + tools = [] + tool_by_call_id = {} + ended_at = "" + task_complete_message = "" + + for event in turn_events: + payload = event.get("payload") or {} + event_type = event.get("type") + payload_type = payload.get("type") + if event_type == "event_msg": + if payload_type == "agent_message": + item = { + "text": str(payload.get("message") or "").strip(), + "phase": payload.get("phase") or "", + } + if item["text"]: + progress_events.append(item) + elif payload_type == "task_complete": + ended_at = event.get("timestamp") or "" + task_complete_message = str(payload.get("last_agent_message") or "").strip() + elif event_type == "response_item": + if payload_type == "message" and payload.get("role") == "assistant": + text = _response_message_text(payload) + if text: + assistant_events.append( + { + "text": text, + "phase": payload.get("phase") or "", + } + ) + elif include_actions and payload_type in ("function_call", "custom_tool_call"): + tool = _rollout_tool_call(payload) + if tool is None: + continue + tools.append(tool) + call_id = tool.get("call_id") or "" + if call_id: + tool_by_call_id[call_id] = tool + elif include_actions and payload_type in ("function_call_output", "custom_tool_call_output"): + tool = tool_by_call_id.get(payload.get("call_id") or "") + if tool is not None: + _apply_rollout_tool_output(tool, payload) + + visible = progress_events or assistant_events + progress = [item["text"] for item in visible] + final_output = visible[-1]["text"] if visible else task_complete_message + final_json = None + if final_output: + final_json = _parse_rollout_final_json(final_output) + if progress and progress[-1] == final_output and ( + final_json is not None or (visible[-1].get("phase") or "") == "final_answer" + ): + progress = progress[:-1] + + if include_actions: + for tool in tools: + tool["summary"] = _rollout_tool_summary(tool) + + return { + "turn_id": started_payload.get("turn_id") or "", + "started_at": started.get("timestamp") or "", + "ended_at": ended_at, + "progress": progress, + "tools": tools, + "final_output": final_output, + "final_json": final_json, + } + + +def _response_message_text(payload): + """Return the text content from one assistant response message.""" + parts = [] + for item in payload.get("content") or []: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + + +def _rollout_tool_call(payload): + """Return a compact tool-call record for one rollout item.""" + name = payload.get("name") or "" + kind = payload.get("type") or "" + call_id = payload.get("call_id") or "" + tool = { + "call_id": call_id, + "kind": kind, + "name": name, + "command": "", + "files": [], + "exit_code": None, + "output": "", + "summary": "", + } + if kind == "function_call": + arguments = _parse_rollout_json(payload.get("arguments")) + if isinstance(arguments, dict): + tool["command"] = str(arguments.get("cmd") or "").strip() + elif kind == "custom_tool_call" and name == "apply_patch": + tool["files"] = _patch_targets(payload.get("input") or "") + return tool + + +def _apply_rollout_tool_output(tool, payload): + """Fold tool output into a compact rollout tool record.""" + text, exit_code = _tool_output_details(payload.get("output")) + if exit_code is not None: + tool["exit_code"] = exit_code + tool["output"] = _snippet(text.strip(), 400) if text else "" + if tool["name"] == "apply_patch" and not tool["files"]: + tool["files"] = _updated_files(text) + + +def _parse_rollout_json(value): + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value.strip(): + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _parse_rollout_final_json(text): + """Return the normalized final agent JSON when the text matches the contract.""" + try: + return _parse_agent_response(text) + except ValueError: + return None + + +def _tool_output_details(output): + """Return normalized output text and exit code from a rollout tool result.""" + text = str(output or "") + payload = _parse_rollout_json(text) + exit_code = None + if isinstance(payload, dict): + metadata = payload.get("metadata") or {} + exit_code = _usage_int(metadata.get("exit_code")) + text = str(payload.get("output") or "") + raw = text + body = raw + if "\nOutput:\n" in raw: + body = raw.split("\nOutput:\n", 1)[1] + elif raw.startswith("Output:\n"): + body = raw.split("Output:\n", 1)[1] + for line in raw.splitlines(): + if not line.startswith("Process exited with code "): + continue + tail = line.rsplit(" ", 1)[-1].strip() + if tail.startswith("-"): + tail = tail[1:] + if tail.isdigit(): + exit_code = int(line.rsplit(" ", 1)[-1].strip()) + break + return body.strip(), exit_code + + +def _rollout_tool_summary(tool): + """Return one readable summary line for a tool action.""" + name = tool.get("name") or "" + exit_code = tool.get("exit_code") + suffix = "" + if exit_code is not None: + suffix = f" (exit {exit_code})" + if name == "exec_command": + command = _single_line(_snippet(tool.get("command") or "", 140)) + if command: + return f"Running command: {command}{suffix}" + return f"Running command{suffix}" + if name == "apply_patch": + files = tool.get("files") or [] + if files: + label = ", ".join(files[:3]) + if len(files) > 3: + label += ", ..." + return f"Editing files: {label}{suffix}" + return f"Editing files{suffix}" + if name: + return f"{name}{suffix}" + return f"tool{suffix}" + + +def _patch_targets(text): + """Return patch target files from an apply_patch input.""" + files = [] + for line in str(text or "").splitlines(): + for prefix in ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "): + if not line.startswith(prefix): + continue + target = line[len(prefix) :].strip() + if target and target not in files: + files.append(target) + return files + + +def _updated_files(text): + """Return file paths mentioned in apply_patch output.""" + files = [] + for line in str(text or "").splitlines(): + line = line.strip() + if not line or line == "Success. Updated the following files:": + continue + if line.startswith(("M ", "A ", "D ")): + target = line[2:].strip() + if target and target not in files: + files.append(target) + return files + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 3078cd2..60812ee 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -29,6 +29,7 @@ send_agent, set_agent_heartbeat, show_agent as show_managed_agent, + status_agent as status_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, uninstall_cron as uninstall_agent_cron, @@ -190,6 +191,48 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_status(result, include_actions=False): + print(f"{result['name']} [{result['agent_status'] or '-'}]") + print(f"ID: {result['id']}") + print(f"Thread: {result.get('thread_id') or '-'}") + print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]") + print(f"Started: {result.get('started_at') or '-'}") + print(f"Ended: {result.get('ended_at') or '-'}") + print(f"CWD: {result.get('cwd') or '-'}") + print(f"Rollout: {result.get('rollout_path') or '-'}") + progress = result.get("progress") or [] + print("Progress:") + if not progress: + print("- none") + else: + for item in progress: + print(f"- {_single_line(item)}") + if include_actions: + tools = result.get("tools") or [] + print("Actions:") + if not tools: + print("- none") + else: + for tool in tools: + print(f"- {tool.get('summary') or tool.get('name') or 'tool'}") + if tool.get("output"): + print(f" Output: {_single_line(tool['output'])}") + final_json = result.get("final_json") + if final_json is not None: + print("Final fields:") + print(f"Status: {final_json.get('status') or '-'}") + print(f"Continue: {str(bool(final_json.get('continue'))).lower()}") + print(f"Reply: {final_json.get('reply') or '-'}") + print(f"Notify: {final_json.get('notify') or '-'}") + return + final_output = result.get("final_output") or "" + print("Final output:") + if final_output: + print(final_output) + else: + print("-") + + def _print_managed_agent_identity(): override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() print(f"Host: {current_hostname()}") @@ -1502,6 +1545,19 @@ def main(argv=None): ) agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_status = agent_subparsers.add_parser( + "status", + help="Show the latest rollout turn for one durable agent.", + ) + agent_status.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_status.add_argument( + "--actions", + "--with-actions", + action="store_true", + dest="actions", + help="Include verbose tool actions from the latest turn.", + ) + agent_read = agent_subparsers.add_parser( "read", help="Read recent visible communication for one agent.", @@ -1915,6 +1971,12 @@ def main(argv=None): if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return + if args.agent_command == "status": + _print_managed_agent_status( + status_managed_agent(args.agent_ref, include_actions=args.actions), + include_actions=args.actions, + ) + return if args.agent_command == "read": if args.limit < 1: raise SystemExit("--limit must be >= 1.") diff --git a/tests/test_agents.py b/tests/test_agents.py index 726f7d5..f5f01be 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -33,6 +33,7 @@ set_agent_heartbeat, show_agent, start_agent, + status_agent, tick, uninstall_cron, write_tick_wrapper, @@ -52,6 +53,26 @@ def _temp_home(): yield Path(tmpdir) +def _write_rollout(path, events): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + + +def _set_rollout_session(home, agent_id, hostname, thread_id, rollout_path): + session_path = home / "agents" / agent_id / "hosts" / hostname / "session.json" + state_path = home / "agents" / agent_id / "state.json" + session = json.loads(session_path.read_text(encoding="utf-8")) + state = json.loads(state_path.read_text(encoding="utf-8")) + session["thread_id"] = thread_id + session["rollout_path"] = str(rollout_path) + state["thread_id"] = thread_id + session_path.write_text(json.dumps(session, indent=2, sort_keys=True) + "\n", encoding="utf-8") + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + class AgentsTests(unittest.TestCase): def test_cli_version(self): output = io.StringIO() @@ -339,6 +360,119 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["status"], "ready") self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + def test_resume_done_agent_reopens_it(self): + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def resume_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Back on it", + "continue": True, + "reply": "Reopened.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "done") + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=resume_runner) + self.assertEqual(resumed["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["reply"], "Reopened.") + + def test_send_wakes_done_agent_once_without_reopening(self): + prompts = [] + + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + def test_send_wakes_canceled_agent_once_without_reopening(self): + prompts = [] + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-canceled", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + control_agent(agent["id"], "cancel", hostname="host-b") + tick(hostname="host-a", runner=reply_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "canceled") + + send_agent(agent["id"], "status", author="mark", hostname="host-b") + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[-1]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "canceled") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + def test_set_agent_heartbeat_updates_meta_and_reschedules_idle_agent(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(minutes=1) @@ -975,6 +1109,369 @@ def test_cli_set_heartbeat_updates_agent(self): shown = show_agent(agent["id"]) self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + def test_status_agent_returns_latest_completed_turn(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-status.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T13:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-old"}, + }, + { + "timestamp": "2026-03-09T13:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Old turn.", + }, + }, + { + "timestamp": "2026-03-09T13:00:02Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-old", + "last_agent_message": "{\"status\":\"Old\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T13:10:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-new"}, + }, + { + "timestamp": "2026-03-09T13:10:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the repository state.", + }, + }, + { + "timestamp": "2026-03-09T13:10:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cmd", + }, + }, + { + "timestamp": "2026-03-09T13:10:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cmd", + "output": "Chunk ID: 123456\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Updating the agent notes now.", + }, + }, + { + "timestamp": "2026-03-09T13:10:05Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "status": "completed", + "call_id": "call-patch", + "input": "*** Begin Patch\n*** Update File: /tmp/AGENTBOOK.md\n@@\n-old\n+new\n*** End Patch\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:06Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call-patch", + "output": json.dumps( + { + "output": "Success. Updated the following files:\nM /tmp/AGENTBOOK.md\n", + "metadata": {"exit_code": 0, "duration_seconds": 0.1}, + } + ), + }, + }, + { + "timestamp": "2026-03-09T13:10:07Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + { + "timestamp": "2026-03-09T13:10:08Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-new", + "last_agent_message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-status", rollout) + + result = status_agent(agent["id"], include_actions=True) + self.assertEqual(result["turn_id"], "turn-new") + self.assertEqual(result["turn_state"], "complete") + self.assertEqual(result["started_at"], "2026-03-09T13:10:00Z") + self.assertEqual(result["ended_at"], "2026-03-09T13:10:08Z") + self.assertEqual( + result["progress"], + [ + "Checking the repository state.", + "Updating the agent notes now.", + ], + ) + self.assertEqual(result["final_json"]["status"], "Ready to merge") + self.assertEqual(result["final_json"]["reply"], "Looks good.") + self.assertEqual(len(result["tools"]), 2) + self.assertEqual(result["tools"][0]["name"], "exec_command") + self.assertEqual(result["tools"][0]["command"], "git status --short") + self.assertEqual(result["tools"][0]["exit_code"], 0) + self.assertEqual(result["tools"][0]["output"], "M README.md") + self.assertEqual(result["tools"][1]["name"], "apply_patch") + self.assertEqual(result["tools"][1]["files"], ["/tmp/AGENTBOOK.md"]) + + def test_status_agent_returns_missing_when_no_rollout_is_known(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + result = status_agent(agent["id"]) + self.assertEqual(result["turn_state"], "missing") + self.assertEqual(result["rollout_path"], "") + self.assertEqual(result["progress"], []) + + def test_status_agent_returns_active_turn_when_run_lock_is_held(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-active.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-active"}, + }, + { + "timestamp": "2026-03-09T14:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the latest CI run now.", + }, + }, + { + "timestamp": "2026-03-09T14:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "gh pr checks 123"}), + "call_id": "call-live", + }, + }, + { + "timestamp": "2026-03-09T14:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-live", + "output": "Chunk ID: 654321\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nci / in_progress\n", + }, + }, + { + "timestamp": "2026-03-09T14:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "The required checks are still running.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-active", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + result = status_agent(agent["id"]) + + self.assertEqual(result["turn_id"], "turn-active") + self.assertEqual(result["turn_state"], "active") + self.assertEqual(result["ended_at"], "") + self.assertEqual(result["final_output"], "The required checks are still running.") + self.assertIsNone(result["final_json"]) + self.assertEqual( + result["progress"], + [ + "Checking the latest CI run now.", + "The required checks are still running.", + ], + ) + + def test_cli_status_shows_latest_turn_details(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T15:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli"}, + }, + { + "timestamp": "2026-03-09T15:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T15:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli", + }, + }, + { + "timestamp": "2026-03-09T15:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli", + "output": "Chunk ID: 111111\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T15:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T15:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", agent["id"]]) + text = output.getvalue() + self.assertIn("Turn: turn-cli [complete]", text) + self.assertIn("Progress:", text) + self.assertIn("Inspecting the latest rollout details.", text) + self.assertIn("Final fields:", text) + self.assertIn("Status: Handled", text) + self.assertNotIn("Final output:", text) + self.assertNotIn("Actions:", text) + + def test_cli_status_with_actions_shows_tool_summaries(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli-actions.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T16:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli-actions"}, + }, + { + "timestamp": "2026-03-09T16:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T16:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli-actions", + }, + }, + { + "timestamp": "2026-03-09T16:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli-actions", + "output": "Chunk ID: 222222\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T16:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T16:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli-actions", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli-actions", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", "--actions", agent["id"]]) + text = output.getvalue() + self.assertIn("Actions:", text) + self.assertIn("Running command: git status --short (exit 0)", text) + if __name__ == "__main__": unittest.main() From 750c5e68da793eae4ab5122800a17bcc2465ee23 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 11 Mar 2026 11:49:58 +0100 Subject: [PATCH 65/78] Improve durable agent runtime and bump version to 0.12.0 --- README.md | 4 ++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 101 +++++++++++++++++++++++++++++++++++---- src/codexapi/cli.py | 38 +++++++++++++-- tests/test_agents.py | 46 +++++++++++++++++- 6 files changed, 176 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ad9e4b1..b8663aa 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ codexapi agent whoami codexapi agent install-cron ``` +`codexapi agent install-cron` installs one background scheduler hook for this +`CODEXAPI_HOME`. The wrapper runs `codexapi tick`, which drives the durable +agent wake scan. + If you skip `install-cron`, `codexapi agent start` warns on stderr because background wakes will not run until the scheduler hook is installed. When `gh` is installed and authenticated, `agent start` also captures a diff --git a/pyproject.toml b/pyproject.toml index 8e25bdc..9c7735b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.11.0" +version = "0.12.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 54c8a7e..b67863c 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.11.0" +__version__ = "0.12.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 247ccc0..636992d 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -33,8 +33,9 @@ "on an ongoing job. Be independent and practical. Manage work and follow " "through. Use codexapi task or codexapi science when you want a separate " "coding worker. If you need the user's attention, put a short message in the " - "reply field. If something is urgent and should send Pushover, put it in the " - "notify field. Respond with JSON only." + "reply field. Put a short first-person turn summary in the update field. If " + "something is urgent and should send Pushover, put it in the notify field. " + "Respond with JSON only." ) _AGENT_JSON = ( "Respond with JSON only (no markdown/backticks/extra text).\n" @@ -42,6 +43,7 @@ " status: string (one line)\n" " continue: boolean\n" " reply: string (optional)\n" + " update: string (recommended; short first-person summary of this turn)\n" " notify: string (optional)\n" ) _COMMAND_KINDS = {"send", "wake", "pause", "resume", "cancel"} @@ -181,6 +183,7 @@ def start_agent( "last_error": "", "activity": "Created", "reply": "", + "update": "", } _write_json(agent_dir / "meta.json", meta) @@ -435,8 +438,9 @@ def delete_agent(agent_ref, force=False, home=None): } -def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): - """Attempt an immediate wake for one locally-owned agent.""" +def run_agent(agent_ref, home=None, hostname=None, now=None, runner=None): + """Run one agent synchronously when it is locally owned.""" + home = _resolve_home(home) agent_dir = resolve_agent_dir(agent_ref, home) meta = _read_json(agent_dir / "meta.json") host = hostname or current_hostname() @@ -451,6 +455,26 @@ def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): } +def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None, wait=True): + """Attempt an immediate wake for one locally-owned agent.""" + home = _resolve_home(home) + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + if runner is not None or wait: + return run_agent(agent_ref, home, host, now, runner) + _spawn_agent_process(meta["id"], home, host) + return { + "ran": True, + "reason": "local", + "processed": 1, + "woken": 1, + "spawned": True, + } + + def tick(home=None, hostname=None, now=None, runner=None): """Process due agents for the current host.""" home = _resolve_home(home) @@ -467,11 +491,19 @@ def tick(home=None, hostname=None, now=None, runner=None): for agent in list_agents(home): if agent["hostname"] != host: continue - outcome = _tick_agent(_agent_dir(home, agent["id"]), now, runner) - if outcome["processed"]: - processed += 1 - if outcome["woken"]: - woken += 1 + agent_dir = _agent_dir(home, agent["id"]) + if runner is not None: + outcome = _tick_agent(agent_dir, now, runner) + if outcome["processed"]: + processed += 1 + if outcome["woken"]: + woken += 1 + continue + if not _agent_needs_tick(agent_dir, now): + continue + _spawn_agent_process(agent["id"], home, host) + processed += 1 + woken += 1 return {"ran": True, "hostname": host, "processed": processed, "woken": woken} @@ -568,7 +600,7 @@ def write_tick_wrapper(home=None, python_executable=None, path_value=None, hostn f"export CODEXAPI_HOME={shlex.quote(str(home))}", f"export CODEXAPI_HOSTNAME={shlex.quote(str(hostname))}", f"export PATH={shlex.quote(path_value)}", - f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", + f"exec {shlex.quote(str(python_executable))} -m codexapi tick", ] _write_text(wrapper, "\n".join(lines) + "\n") wrapper.chmod(0o755) @@ -618,6 +650,35 @@ def _tick_agent(agent_dir, now, runner): return {"processed": True, "woken": True} +def _agent_needs_tick(agent_dir, now): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + if _run_lock_held(run_lock_path): + return False + if _has_new_commands(agent_dir): + return True + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: + return False + return _is_due(state, now) + + +def _spawn_agent_process(agent_id, home, hostname): + env = dict(os.environ) + env["CODEXAPI_HOME"] = str(home) + env["CODEXAPI_HOSTNAME"] = str(hostname) + subprocess.Popen( + [sys.executable, "-m", "codexapi", "agent", "run", agent_id], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + close_fds=True, + start_new_session=True, + ) + + def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""): prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) state["status"] = "running" @@ -636,6 +697,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal "messages": [], "status": "", "reply": "", + "update": "", "notify": "", "error": "", "continue": True, @@ -661,6 +723,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal usage = _normalize_usage(outcome.get("usage")) _add_usage(meta, state, usage, ended) state["reply"] = response["reply"] + state["update"] = response["update"] state["last_success_at"] = format_utc(ended) state["last_error"] = "" state["thread_id"] = session["thread_id"] @@ -683,6 +746,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal run["ended_at"] = format_utc(ended) run["status"] = response["status"] run["reply"] = response["reply"] + run["update"] = response["update"] run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) run["usage"] = usage @@ -757,6 +821,7 @@ def _parse_agent_response(output): status = payload.get("status") cont = payload.get("continue") reply = payload.get("reply") + update = payload.get("update") notify = payload.get("notify") if not isinstance(status, str) or not status.strip(): raise ValueError("Agent response missing string 'status'.") @@ -764,16 +829,21 @@ def _parse_agent_response(output): raise ValueError("Agent response missing boolean 'continue'.") if reply is None: reply = "" + if update is None: + update = reply or status or "" if notify is None: notify = "" if not isinstance(reply, str): raise ValueError("Agent response missing string 'reply'.") + if not isinstance(update, str): + raise ValueError("Agent response missing string 'update'.") if not isinstance(notify, str): raise ValueError("Agent response missing string 'notify'.") return { "status": _single_line(status), "continue": cont, "reply": reply.strip(), + "update": update.strip(), "notify": notify.strip(), } @@ -986,6 +1056,7 @@ def _snapshot(agent_dir, child_map=None): "last_error": state.get("last_error") or "", "activity": state.get("activity") or "", "reply": state.get("reply") or "", + "update": state.get("update") or "", } @@ -1323,6 +1394,16 @@ def _queued_send_commands(agent_dir): return queued +def _has_new_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return False + for path in new_dir.iterdir(): + if path.is_file() and path.suffix == ".json": + return True + return False + + def _child_map(home): """Return parent_id -> [child ids] for this home.""" root = _resolve_home(home) / "agents" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 60812ee..45415e9 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -26,6 +26,7 @@ nudge_agent, read_agent as read_managed_agent, read_agentbook, + run_agent as run_managed_agent, send_agent, set_agent_heartbeat, show_agent as show_managed_agent, @@ -223,6 +224,7 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Status: {final_json.get('status') or '-'}") print(f"Continue: {str(bool(final_json.get('continue'))).lower()}") print(f"Reply: {final_json.get('reply') or '-'}") + print(f"Update: {final_json.get('update') or '-'}") print(f"Notify: {final_json.get('notify') or '-'}") return final_output = result.get("final_output") or "" @@ -273,6 +275,10 @@ def _warn_agent_scheduler_missing(): print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) +def _system_tick(): + return {"agents": tick_managed_agents()} + + def _send_reply_info(agent_ref, message_id): """Return the matching run reply for one sent message, if already delivered.""" shown = show_managed_agent(agent_ref) @@ -286,9 +292,12 @@ def _send_reply_info(agent_ref, message_id): "run_id": run.get("id") or "", } reply = run.get("reply") or "" + update = run.get("update") or "" error = run.get("error") or "" if reply: info["agent_reply"] = reply + if update: + info["agent_update"] = update if error: info["agent_error"] = error return info @@ -319,6 +328,7 @@ def _print_managed_agent_show(result): ) print(f"Activity: {_state_text(state.get('activity'))}") print(f"Reply: {_state_text(state.get('reply'))}") + print(f"Update: {_state_text(state.get('update'))}") print(f"Last error: {_state_text(state.get('last_error'))}") print(f"Last wake: {_state_time(state.get('last_wake_at'))}") print(f"Last success: {_state_time(state.get('last_success_at'))}") @@ -448,11 +458,14 @@ def _format_managed_agent_run(run): tokens = _format_token_total(usage.get("total_tokens")) status = run.get("error") or run.get("status") or "-" reply = run.get("reply") or "" + update = run.get("update") or "" message_count = len(run.get("messages") or []) parts = [started, reason, tokens] if message_count: parts.append(f"msgs={message_count}") summary = _truncate_head(_single_line(status), 60) + if update: + summary = _truncate_head(f"{summary} | {_single_line(update)}", 100) if reply: summary = _truncate_head(f"{summary} | {_single_line(reply)}", 100) parts.append(summary) @@ -1539,6 +1552,12 @@ def main(argv=None): help="Show the effective host and CODEXAPI_HOME for agents.", ) + agent_run = agent_subparsers.add_parser( + "run", + help=argparse.SUPPRESS, + ) + agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) + agent_show = agent_subparsers.add_parser( "show", help="Show one durable agent.", @@ -1643,6 +1662,11 @@ def main(argv=None): help="Remove the cron entry for this CODEXAPI_HOME.", ) + subparsers.add_parser( + "tick", + help="Run one full background tick.", + ) + task_parser = subparsers.add_parser( "task", help="Run a task with verification retries.", @@ -1958,7 +1982,7 @@ def main(argv=None): ) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(result["id"]) + result["nudge"] = nudge_agent(result["id"], wait=True) _warn_agent_scheduler_missing() print(json.dumps(result, indent=2, sort_keys=True)) return @@ -1968,6 +1992,9 @@ def main(argv=None): if args.agent_command == "whoami": _print_managed_agent_identity() return + if args.agent_command == "run": + print(json.dumps(run_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + return if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return @@ -1989,7 +2016,7 @@ def main(argv=None): result = send_agent(args.agent_ref, args.message, args.author) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) reply_info = _send_reply_info(args.agent_ref, result["id"]) if reply_info: result.update(reply_info) @@ -2003,7 +2030,7 @@ def main(argv=None): ) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("pause", "cancel"): @@ -2013,7 +2040,7 @@ def main(argv=None): args.author, ) result["waited"] = False - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "set-heartbeat": @@ -2048,6 +2075,9 @@ def main(argv=None): if args.agent_command == "uninstall-cron": print(json.dumps(uninstall_agent_cron(), indent=2, sort_keys=True)) return + if args.command == "tick": + print(json.dumps(_system_tick(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index f5f01be..fbf2c27 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -566,7 +566,7 @@ def test_write_tick_wrapper_pins_home_and_python(self): self.assertIn("export CODEXAPI_HOSTNAME=stable-host", text) self.assertIn("export PATH=", text) self.assertIn("/tmp/venv/bin:/usr/bin", text) - self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) + self.assertIn("exec /tmp/venv/bin/python -m codexapi tick", text) def test_upsert_cron_line_keeps_different_homes_separate(self): line_a = render_cron_line(home="/tmp/home-a", hostname="host-a") @@ -711,6 +711,50 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["reply"], "Message handled.") self.assertEqual(shown["unread_message_count"], 0) + def test_nudge_agent_can_spawn_async_process(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = nudge_agent( + agent["id"], + home=home, + hostname="host-a", + wait=False, + ) + self.assertTrue(result["ran"]) + self.assertTrue(result["spawned"]) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0][-2:], ["run", agent["id"]]) + + def test_tick_spawns_one_process_per_due_local_agent(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + first = start_agent("First job.", hostname="host-a") + second = start_agent("Second job.", hostname="host-a") + start_agent("Remote job.", hostname="host-b") + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = tick(home=home, hostname="host-a") + self.assertTrue(result["ran"]) + self.assertEqual(result["processed"], 2) + self.assertEqual(result["woken"], 2) + self.assertEqual(len(calls), 2) + spawned = sorted(call[0][-1] for call in calls) + self.assertEqual(spawned, sorted([first["id"], second["id"]])) + def test_codex_rollout_usage_uses_latest_event_after_start(self): started = datetime(2026, 3, 6, 8, 0, 5, tzinfo=timezone.utc) with _temp_home() as home: From d8fb58af079755b9dd983f8e422fbfdf329bdf45 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 13 Mar 2026 09:21:05 +0100 Subject: [PATCH 66/78] Add stale wake recovery for agents --- README.md | 6 ++ src/codexapi/agents.py | 182 ++++++++++++++++++++++++++++++++++++++++- src/codexapi/cli.py | 39 +++++++++ tests/test_agents.py | 159 ++++++++++++++++++++++++++++++++++- 4 files changed, 383 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8663aa..22aa230 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,8 @@ codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent resume --wait ci-fixer +codexapi agent recover ci-fixer +codexapi agent recover --wait ci-fixer codexapi agent set-heartbeat ci-fixer 30 codexapi agent cancel ci-fixer codexapi agent delete ci-fixer @@ -211,6 +213,10 @@ codexapi agent delete ci-fixer `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or `canceled` agent still triggers a one-off wake on the next tick so you can get a reply without putting the agent back into continuous heartbeat mode. +`codexapi agent recover` is for a different failure mode: a local wake that is +still marked `running` but has stopped making rollout progress. `agent list`, +`agent show`, and `agent status` now surface stale running wakes, and `recover` +terminates the stuck local wake, marks it recoverable, and queues a fresh one. Create a child agent explicitly: diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 636992d..e3dbde9 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,6 +3,7 @@ import json import os import random +import signal import shlex import shutil import socket @@ -10,6 +11,7 @@ import subprocess import sys import tempfile +import time import uuid from contextlib import contextmanager from datetime import datetime, timedelta, timezone @@ -50,6 +52,11 @@ _STOP_POLICIES = {"until_done", "until_stopped"} _TERMINAL_STATES = {"done", "canceled"} _ACTIVE_STATES = {"ready", "error", "running", "paused"} +_STALE_MIN_SECONDS = 30 * 60 +_STALE_HEARTBEAT_MULTIPLIER = 3 +_RECOVER_TERM_TIMEOUT = 3.0 +_RECOVER_KILL_TIMEOUT = 3.0 +_RECOVER_POLL_INTERVAL = 0.1 def codexapi_home(): @@ -223,6 +230,11 @@ def show_agent(agent_ref, home=None): snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] + snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"] + snapshot["state"]["last_event_at"] = snapshot["last_event_at"] + snapshot["state"]["stale"] = snapshot["stale"] + snapshot["state"]["stale_after_seconds"] = snapshot["stale_after_seconds"] + snapshot["state"]["stale_for_seconds"] = snapshot["stale_for_seconds"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) snapshot["parent"] = _agent_brief(home, snapshot["parent_id"], child_map) @@ -256,6 +268,11 @@ def status_agent(agent_ref, home=None, include_actions=False): "tools": [], "final_output": "", "final_json": None, + "run_lock_held": snapshot["run_lock_held"], + "last_event_at": snapshot["last_event_at"], + "stale": snapshot["stale"], + "stale_after_seconds": snapshot["stale_after_seconds"], + "stale_for_seconds": snapshot["stale_for_seconds"], } if rollout_path is None or not rollout_path.exists(): return result @@ -266,9 +283,13 @@ def status_agent(agent_ref, home=None, include_actions=False): run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock" turn_state = "complete" if not turn["ended_at"]: - turn_state = "active" if _run_lock_held(run_lock_path) else "interrupted" + if _run_lock_held(run_lock_path): + turn_state = "stale" if snapshot["stale"] else "active" + else: + turn_state = "interrupted" result.update(turn) result["turn_state"] = turn_state + result["last_event_at"] = turn.get("last_event_at") or result["last_event_at"] return result @@ -409,6 +430,56 @@ def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None): } +def recover_agent(agent_ref, home=None, hostname=None, now=None): + """Recover one local running agent by clearing a stuck wake and requeueing it.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + if meta["hostname"] != host: + raise ValueError("Cannot recover a remote agent from this host.") + state_path = agent_dir / "state.json" + state = _read_json(state_path) + if (state.get("status") or "") != "running": + raise ValueError("Recover only applies to agents in the running state.") + session_path = agent_dir / "hosts" / meta["hostname"] / "session.json" + session = _read_json(session_path) + runtime = _agent_runtime(agent_dir, meta, state, session, now) + signal_result = { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + if runtime["run_lock_held"]: + signal_result = _recover_run_lock( + agent_dir / "hosts" / meta["hostname"] / "run.lock" + ) + state = _read_json(state_path) + session = _read_json(session_path) + state["status"] = "error" + state["last_error"] = "Recovered stuck wake." + state["activity"] = state["last_error"] + state["wake_requested_at"] = format_utc(now) + _sync_state_from_session(state, session) + _write_json(state_path, state) + return { + "id": meta["id"], + "name": meta["name"], + "status": state["status"], + "recovered": True, + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], + "wake_requested_at": state["wake_requested_at"], + "last_error": state["last_error"], + "signal": signal_result, + } + + def delete_agent(agent_ref, force=False, home=None): """Delete one agent directory when it is safe to do so.""" home = _resolve_home(home) @@ -1024,6 +1095,8 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): def _snapshot(agent_dir, child_map=None): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + runtime = _agent_runtime(agent_dir, meta, state, session) if child_map is None: child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) else: @@ -1057,9 +1130,50 @@ def _snapshot(agent_dir, child_map=None): "activity": state.get("activity") or "", "reply": state.get("reply") or "", "update": state.get("update") or "", + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], } +def _agent_runtime(agent_dir, meta, state, session=None, now=None): + """Return live wake health for one agent.""" + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + run_lock_held = _run_lock_held(run_lock_path) + stale_after_seconds = _stale_after_seconds(meta.get("heartbeat_minutes") or 0) + info = { + "run_lock_held": run_lock_held, + "last_event_at": "", + "stale": False, + "stale_after_seconds": stale_after_seconds, + "stale_for_seconds": 0, + } + if (state.get("status") or "") != "running" and not run_lock_held: + return info + now = now or utc_now() + session = session or _read_session(agent_dir) + thread_id = session.get("thread_id") or state.get("thread_id") or "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is not None and rollout_path.exists(): + turn = _last_rollout_turn(_rollout_events(rollout_path)) + if turn is not None: + info["last_event_at"] = turn.get("last_event_at") or turn.get("started_at") or "" + last_progress_at = parse_utc(info["last_event_at"]) or parse_utc(state.get("last_wake_at")) + if last_progress_at is None: + return info + idle = max(0, int((now - last_progress_at).total_seconds())) + info["stale_for_seconds"] = idle + info["stale"] = run_lock_held and idle >= stale_after_seconds + return info + + +def _stale_after_seconds(heartbeat_minutes): + minutes = int(heartbeat_minutes or 0) + return max(_STALE_MIN_SECONDS, minutes * 60 * _STALE_HEARTBEAT_MULTIPLIER) + + def _choose_name(home, prompt, requested): base = _slugify(requested or prompt) if not base: @@ -1187,6 +1301,69 @@ def _run_lock_held(path): return handle is None +def _lock_info(path): + try: + return _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _signal_run_process(pid, sig): + """Signal the wake process group when possible, else just the pid.""" + pgid = None + try: + pgid = os.getpgid(pid) + except OSError: + pgid = None + if pgid is not None: + os.killpg(pgid, sig) + else: + os.kill(pid, sig) + return pgid + + +def _wait_for_lock_release(path, timeout_seconds): + deadline = time.monotonic() + max(0.0, float(timeout_seconds)) + while time.monotonic() < deadline: + if not _run_lock_held(path): + return True + time.sleep(_RECOVER_POLL_INTERVAL) + return not _run_lock_held(path) + + +def _recover_run_lock(path): + """Terminate the current lock holder and wait for the wake lock to clear.""" + if not _run_lock_held(path): + return { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + info = _lock_info(path) + pid = _usage_int(info.get("pid")) + if pid is None: + raise ValueError("Run lock is held but has no recorded pid.") + result = { + "pid": pid, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + try: + result["pgid"] = _signal_run_process(pid, signal.SIGTERM) + result["sent_sigterm"] = True + except ProcessLookupError: + result["pgid"] = None + if _wait_for_lock_release(path, _RECOVER_TERM_TIMEOUT): + return result + result["pgid"] = _signal_run_process(pid, signal.SIGKILL) + result["sent_sigkill"] = True + if _wait_for_lock_release(path, _RECOVER_KILL_TIMEOUT): + return result + raise ValueError("Run lock stayed held after SIGTERM/SIGKILL.") + + def _read_session(agent_dir): meta = _read_json(agent_dir / "meta.json") return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") @@ -1559,6 +1736,7 @@ def _last_rollout_turn(events, include_actions=False): started = turn_events[0] started_payload = started.get("payload") or {} + last_event_at = "" progress_events = [] assistant_events = [] tools = [] @@ -1567,6 +1745,7 @@ def _last_rollout_turn(events, include_actions=False): task_complete_message = "" for event in turn_events: + last_event_at = event.get("timestamp") or last_event_at payload = event.get("payload") or {} event_type = event.get("type") payload_type = payload.get("type") @@ -1623,6 +1802,7 @@ def _last_rollout_turn(events, include_actions=False): "turn_id": started_payload.get("turn_id") or "", "started_at": started.get("timestamp") or "", "ended_at": ended_at, + "last_event_at": last_event_at, "progress": progress, "tools": tools, "final_output": final_output, diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 45415e9..271e36a 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -26,6 +26,7 @@ nudge_agent, read_agent as read_managed_agent, read_agentbook, + recover_agent as recover_managed_agent, run_agent as run_managed_agent, send_agent, set_agent_heartbeat, @@ -201,6 +202,8 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Ended: {result.get('ended_at') or '-'}") print(f"CWD: {result.get('cwd') or '-'}") print(f"Rollout: {result.get('rollout_path') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") progress = result.get("progress") or [] print("Progress:") if not progress: @@ -319,6 +322,8 @@ def _print_managed_agent_show(result): print(f"CWD: {meta['cwd']}") print(f"Agentbook: {result.get('agentbook_path') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") print( "Tokens: " f"{_format_token_total(state.get('total_tokens'))} total " @@ -411,6 +416,12 @@ def _policy_label(stop_policy): def _next_wake_label(item): status = item.get("status") or "" + if status == "running": + if item.get("stale"): + return "stale" + if item.get("run_lock_held"): + return "run" + return "lost" if status in ("done", "canceled"): return "-" if status == "paused": @@ -451,6 +462,16 @@ def _state_time(value): return value or "-" +def _stale_text(item): + if not item.get("run_lock_held"): + return "no" + threshold = _format_duration(item.get("stale_after_seconds")) + idle = _format_duration(item.get("stale_for_seconds")) + if item.get("stale"): + return f"yes ({idle} idle; threshold {threshold})" + return f"no ({idle} idle; threshold {threshold})" + + def _format_managed_agent_run(run): started = run.get("started_at") or "-" reason = run.get("wake_reason") or "-" @@ -1624,6 +1645,17 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) + agent_recover = agent_subparsers.add_parser( + "recover", + help="Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", + ) + agent_recover.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_recover.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after recovery.", + ) + agent_set_heartbeat = agent_subparsers.add_parser( "set-heartbeat", help="Update the heartbeat interval for one durable agent.", @@ -2043,6 +2075,13 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "recover": + result = recover_managed_agent(args.agent_ref) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref, wait=True) + print(json.dumps(result, indent=2, sort_keys=True)) + return if args.agent_command == "set-heartbeat": if args.heartbeat_minutes < 0: raise SystemExit("heartbeat_minutes must be >= 0.") diff --git a/tests/test_agents.py b/tests/test_agents.py index fbf2c27..6c6c182 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -28,6 +28,7 @@ nudge_agent, read_agent, read_agentbook, + recover_agent, render_cron_line, send_agent, set_agent_heartbeat, @@ -42,6 +43,7 @@ _print_managed_agent_identity, _print_managed_agent_list, _print_managed_agent_show, + _print_managed_agent_status, main as cli_main, ) @@ -306,7 +308,12 @@ def __call__(self, prompt): with patch("codexapi.agents.Agent", FakeAgent): with patch( "codexapi.agents.utc_now", - side_effect=[start, start + timedelta(seconds=1), end], + side_effect=[ + start, + start + timedelta(seconds=1), + start + timedelta(seconds=2), + end, + ], ): result = nudge_agent( parent["id"], @@ -1153,6 +1160,150 @@ def test_cli_set_heartbeat_updates_agent(self): shown = show_agent(agent["id"]) self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + def test_cli_views_mark_stale_running_agent(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + stale_now = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + rollout = home / "rollouts" / "rollout-thread-stale.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-stale"}, + }, + { + "timestamp": "2026-03-09T14:00:10Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Still checking.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-stale", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with patch("codexapi.agents.utc_now", return_value=stale_now): + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + shown = show_agent(agent["id"]) + status = status_agent(agent["id"]) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + + status_out = io.StringIO() + with redirect_stdout(status_out): + _print_managed_agent_status(status) + + self.assertTrue(shown["run_lock_held"]) + self.assertTrue(shown["stale"]) + self.assertEqual(shown["last_event_at"], "2026-03-09T14:00:10Z") + self.assertEqual(status["turn_state"], "stale") + self.assertEqual(status["last_event_at"], "2026-03-09T14:00:10Z") + self.assertIn("stale", list_out.getvalue()) + self.assertIn("Stale: yes", show_out.getvalue()) + self.assertIn("Turn: turn-stale [stale]", status_out.getvalue()) + + def test_recover_agent_marks_running_agent_error_and_requests_wake(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + recover_at = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + lock_path.write_text( + json.dumps( + {"pid": 4321, "hostname": "host-a", "started_at": format_utc(start)} + ) + + "\n", + encoding="utf-8", + ) + + with patch( + "codexapi.agents._agent_runtime", + return_value={ + "run_lock_held": True, + "last_event_at": "2026-03-09T14:00:10Z", + "stale": True, + "stale_after_seconds": 1800, + "stale_for_seconds": 7190, + }, + ): + with patch( + "codexapi.agents._recover_run_lock", + return_value={ + "pid": 4321, + "pgid": 4321, + "sent_sigterm": True, + "sent_sigkill": False, + }, + ) as recover_lock: + result = recover_agent(agent["id"], hostname="host-a", now=recover_at) + + shown = show_agent(agent["id"]) + recover_lock.assert_called_once() + self.assertEqual(recover_lock.call_args[0][0], lock_path.resolve()) + self.assertTrue(result["recovered"]) + self.assertTrue(result["stale"]) + self.assertEqual(result["signal"]["pid"], 4321) + self.assertEqual(shown["state"]["status"], "error") + self.assertEqual(shown["state"]["last_error"], "Recovered stuck wake.") + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(recover_at)) + + def test_cli_recover_wait_nudges_after_recovery(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + output = io.StringIO() + with patch( + "codexapi.cli.recover_managed_agent", + return_value={"id": agent["id"], "name": agent["name"], "status": "error"}, + ) as recover_mock: + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "recover", "--wait", agent["id"]]) + payload = json.loads(output.getvalue()) + recover_mock.assert_called_once_with(agent["id"]) + nudge_mock.assert_called_once_with(agent["id"], wait=True) + self.assertTrue(payload["waited"]) + self.assertEqual(payload["nudge"]["woken"], 1) + def test_status_agent_returns_latest_completed_turn(self): with _temp_home() as home: agent = start_agent("Handle messages.", hostname="host-a") @@ -1358,7 +1509,11 @@ def test_status_agent_returns_active_turn_when_run_lock_is_held(self): lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" with _try_lock(lock_path) as handle: self.assertIsNotNone(handle) - result = status_agent(agent["id"]) + with patch( + "codexapi.agents.utc_now", + return_value=datetime(2026, 3, 9, 14, 5, tzinfo=timezone.utc), + ): + result = status_agent(agent["id"]) self.assertEqual(result["turn_id"], "turn-active") self.assertEqual(result["turn_state"], "active") From ca269cc2c6202cc0763994821a5a53a6ed92bae1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 13 Mar 2026 11:17:43 +0100 Subject: [PATCH 67/78] Bump version to 0.12.1 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9c7735b..f6c21b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.0" +version = "0.12.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b67863c..ed40579 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.0" +__version__ = "0.12.1" From 14d38bf7cc36bbfe1cb87218d902b4f8ddf00209 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 15 Mar 2026 17:05:04 +0100 Subject: [PATCH 68/78] Improve queued command UX for agents --- README.md | 6 ++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 58 +++++++++++++++++++++++++++++++---- src/codexapi/cli.py | 33 ++++++++++++++------ tests/test_agents.py | 65 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 22aa230..6592202 100644 --- a/README.md +++ b/README.md @@ -213,10 +213,16 @@ codexapi agent delete ci-fixer `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or `canceled` agent still triggers a one-off wake on the next tick so you can get a reply without putting the agent back into continuous heartbeat mode. +For local-owned agents, `send`, `wake`, and `resume` now also nudge an +immediate non-blocking wake even without `--wait`; `--wait` only changes +whether the CLI blocks for completion. `codexapi agent recover` is for a different failure mode: a local wake that is still marked `running` but has stopped making rollout progress. `agent list`, `agent show`, and `agent status` now surface stale running wakes, and `recover` terminates the stuck local wake, marks it recoverable, and queues a fresh one. +`agent list` also surfaces queued operator intent for local commands, so a +paused agent with a queued `resume` shows as `resuming` with separate queued +message and queued command counts. Create a child agent explicitly: diff --git a/pyproject.toml b/pyproject.toml index f6c21b5..cdf2433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.1" +version = "0.12.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index ed40579..7c1c054 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.1" +__version__ = "0.12.2" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index e3dbde9..29d1447 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -230,6 +230,8 @@ def show_agent(agent_ref, home=None): snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] + snapshot["state"]["pending_command_count"] = snapshot["pending_command_count"] + snapshot["state"]["pending_commands"] = snapshot["pending_commands"] snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"] snapshot["state"]["last_event_at"] = snapshot["last_event_at"] snapshot["state"]["stale"] = snapshot["stale"] @@ -256,7 +258,8 @@ def status_agent(agent_ref, home=None, include_actions=False): result = { "id": snapshot["id"], "name": snapshot["name"], - "agent_status": snapshot["status"], + "agent_status": snapshot["display_status"], + "state_status": snapshot["status"], "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "", "rollout_path": str(rollout_path) if rollout_path else "", "turn_id": "", @@ -273,6 +276,9 @@ def status_agent(agent_ref, home=None, include_actions=False): "stale": snapshot["stale"], "stale_after_seconds": snapshot["stale_after_seconds"], "stale_for_seconds": snapshot["stale_for_seconds"], + "pending_command_count": snapshot["pending_command_count"], + "pending_commands": snapshot["pending_commands"], + "queued_message_count": snapshot["unread_message_count"], } if rollout_path is None or not rollout_path.exists(): return result @@ -1097,13 +1103,17 @@ def _snapshot(agent_dir, child_map=None): state = _read_json(agent_dir / "state.json") session = _read_session(agent_dir) runtime = _agent_runtime(agent_dir, meta, state, session) + queued = _queued_commands(agent_dir) + queued_controls = [item for item in queued if item.get("kind") != "send"] + queued_kinds = [item.get("kind") or "" for item in queued_controls if item.get("kind")] if child_map is None: child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) else: child_ids = child_map.get(meta["id"], []) unread = int(state.get("unread_message_count") or 0) + len( - _queued_send_commands(agent_dir) + [item for item in queued if item.get("kind") == "send"] ) + status = state.get("status") or "" return { "id": meta["id"], "name": meta["name"], @@ -1114,13 +1124,21 @@ def _snapshot(agent_dir, child_map=None): "cwd": meta["cwd"], "stop_policy": meta["stop_policy"], "heartbeat_minutes": meta["heartbeat_minutes"], - "status": state.get("status") or "", + "status": status, + "display_status": _display_status( + status, + queued_kinds, + runtime["run_lock_held"], + runtime["stale"], + ), "thread_id": state.get("thread_id") or "", "last_wake_at": state.get("last_wake_at") or "", "last_success_at": state.get("last_success_at") or "", "next_wake_at": state.get("next_wake_at") or "", "wake_requested_at": state.get("wake_requested_at") or "", "unread_message_count": unread, + "pending_command_count": len(queued_controls), + "pending_commands": queued_kinds, "input_tokens": int(state.get("input_tokens") or 0), "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), @@ -1554,7 +1572,29 @@ def _usage_int(value): return None -def _queued_send_commands(agent_dir): +def _display_status(status, pending_commands, run_lock_held, stale): + """Return a user-facing status that includes queued control intent.""" + state = str(status or "") + commands = [str(kind or "") for kind in pending_commands or [] if kind] + if stale: + return "stale" + if commands: + last = commands[-1] + if last == "resume" and state in ("paused", "done"): + return "resuming" + if last == "pause" and state in ("ready", "error", "running"): + return "pausing" + if last == "cancel" and state != "canceled": + return "canceling" + if last == "wake" and state in ("ready", "error"): + return "waking" + if run_lock_held and state == "running": + return "running" + return state or "" + + +def _queued_commands(agent_dir, kind=None): + """Return queued command payloads from commands/new.""" queued = [] new_dir = agent_dir / "commands" / "new" if not new_dir.exists(): @@ -1566,11 +1606,17 @@ def _queued_send_commands(agent_dir): payload = _read_json(path) except (FileNotFoundError, json.JSONDecodeError): continue - if payload.get("kind") == "send": - queued.append(payload) + payload_kind = payload.get("kind") or "" + if kind and payload_kind != kind: + continue + queued.append(payload) return queued +def _queued_send_commands(agent_dir): + return _queued_commands(agent_dir, "send") + + def _has_new_commands(agent_dir): new_dir = agent_dir / "commands" / "new" if not new_dir.exists(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 271e36a..77d97ca 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -158,20 +158,21 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT POL HOST UNR TOKENS TOK/H NEXT REPO NAME") + print("ID STAT POL HOST QMSG QCMD TOKENS TOK/H NEXT REPO NAME") for item in items: ident = item["id"][:8] - status = _truncate_head(item["status"] or "-", 8) + status = _truncate_head(item.get("display_status") or item.get("status") or "-", 9) policy = _truncate_head(_policy_label(item.get("stop_policy")), 4) host = _truncate_head(item["hostname"] or "-", 12) - unread = str(item["unread_message_count"]) + queued_messages = str(item["unread_message_count"]) + queued_commands = str(int(item.get("pending_command_count") or 0)) tokens = _format_token_total(item["total_tokens"]) tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) next_wake = _truncate_head(_next_wake_label(item), 6) repo = _truncate_head(_repo_label(item.get("cwd")), 12) name = item["name"] print( - f"{ident:<8} {status:<8} {policy:<4} {host:<12} {unread:>3} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" + f"{ident:<8} {status:<9} {policy:<4} {host:<12} {queued_messages:>4} {queued_commands:>4} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" ) @@ -196,6 +197,7 @@ def _print_managed_agent_read(result): def _print_managed_agent_status(result, include_actions=False): print(f"{result['name']} [{result['agent_status'] or '-'}]") print(f"ID: {result['id']}") + print(f"State: {result.get('state_status') or '-'}") print(f"Thread: {result.get('thread_id') or '-'}") print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]") print(f"Started: {result.get('started_at') or '-'}") @@ -204,6 +206,8 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Rollout: {result.get('rollout_path') or '-'}") print(f"Last event: {result.get('last_event_at') or '-'}") print(f"Stale: {_stale_text(result)}") + print(f"Queued messages: {result.get('queued_message_count') or 0}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") progress = result.get("progress") or [] print("Progress:") if not progress: @@ -310,20 +314,22 @@ def _send_reply_info(agent_ref, message_id): def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] - print(f"{meta['name']} [{state.get('status') or '-'}]") + print(f"{meta['name']} [{result.get('display_status') or state.get('status') or '-'}]") print(f"ID: {meta['id']}") print(f"Host: {meta['hostname']}") print(f"Created: {meta['created_at']} by {meta['created_by']}") print(f"Parent: {_related_label(result.get('parent'))}") print(f"Children: {_children_label(result.get('children'))}") print( - f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" + f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Qmsg: {result['unread_message_count']} Qcmd: {result.get('pending_command_count') or 0}" ) print(f"CWD: {meta['cwd']}") print(f"Agentbook: {result.get('agentbook_path') or '-'}") + print(f"State: {state.get('status') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") print(f"Last event: {result.get('last_event_at') or '-'}") print(f"Stale: {_stale_text(result)}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") print( "Tokens: " f"{_format_token_total(state.get('total_tokens'))} total " @@ -416,12 +422,15 @@ def _policy_label(stop_policy): def _next_wake_label(item): status = item.get("status") or "" + display_status = item.get("display_status") or status if status == "running": if item.get("stale"): return "stale" if item.get("run_lock_held"): return "run" return "lost" + if display_status in ("resuming", "waking", "pausing", "canceling"): + return "wake" if status in ("done", "canceled"): return "-" if status == "paused": @@ -472,6 +481,13 @@ def _stale_text(item): return f"no ({idle} idle; threshold {threshold})" +def _pending_commands_text(value): + commands = [str(item or "") for item in value or [] if str(item or "").strip()] + if not commands: + return "-" + return ", ".join(commands) + + def _format_managed_agent_run(run): started = run.get("started_at") or "-" reason = run.get("wake_reason") or "-" @@ -2047,8 +2063,8 @@ def main(argv=None): if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["waited"] = bool(args.wait) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref, wait=True) reply_info = _send_reply_info(args.agent_ref, result["id"]) if reply_info: result.update(reply_info) @@ -2061,8 +2077,7 @@ def main(argv=None): args.author, ) result["waited"] = bool(args.wait) - if args.wait: - result["nudge"] = nudge_agent(args.agent_ref, wait=True) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("pause", "cancel"): diff --git a/tests/test_agents.py b/tests/test_agents.py index 6c6c182..0aa9188 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1051,6 +1051,8 @@ def fake_runner(meta, session, prompt): with redirect_stdout(list_out): _print_managed_agent_list([shown]) self.assertIn("POL", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) self.assertIn("REPO", list_out.getvalue()) self.assertIn("done", list_out.getvalue()) self.assertIn("codexapi", list_out.getvalue()) @@ -1060,6 +1062,7 @@ def fake_runner(meta, session, prompt): _print_managed_agent_show(shown) text = show_out.getvalue() self.assertIn("Policy: until_done", text) + self.assertIn("Qmsg: 0 Qcmd: 0", text) self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text) self.assertIn("Prompt: Handle messages.", text) self.assertIn("Recent runs:", text) @@ -1087,15 +1090,71 @@ def test_cli_send_queues_by_default(self): hostname="host-a", ) output = io.StringIO() - with redirect_stdout(output): - cli_main(["agent", "send", agent["id"], "status"]) + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) payload = json.loads(output.getvalue()) self.assertFalse(payload["waited"]) - self.assertNotIn("nudge", payload) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) shown = show_agent(agent["id"]) self.assertEqual(shown["unread_message_count"], 1) self.assertEqual(shown["state"]["status"], "ready") + def test_cli_resume_without_wait_async_nudges_and_list_shows_resuming(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-resume-ui", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + + output = io.StringIO() + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "resume", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "paused") + self.assertEqual(shown["display_status"], "resuming") + self.assertEqual(shown["pending_commands"], ["resume"]) + self.assertEqual(shown["pending_command_count"], 1) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("resuming", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("[resuming]", text) + self.assertIn("State: paused", text) + self.assertIn("Pending commands: resume", text) + def test_cli_send_wait_shows_immediate_agent_reply(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): From aefc9e3bf01a079a730e80b4a491b39bdfc6950e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 10:43:06 +0000 Subject: [PATCH 69/78] Improve durable agent stewardship defaults --- README.md | 4 +- docs/agent-v1.md | 11 ++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 241 ++++++++++++++++++++++++++++++++++++--- src/codexapi/lead.py | 74 +++++++++++- tests/test_agents.py | 134 ++++++++++++++++++++++ 7 files changed, 444 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6592202..c5a3ae4 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test wrappers report inconsistent hostnames for the same machine. `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can -jump directly to the durable working memory file. +jump directly to the durable working memory file. New agents seed the book with +a purpose/value header plus the original goal and standing guidance, and wakes +see that stable header together with the latest working notes. `codexapi agent status` reads the latest turn from the agent's rollout log and shows recent commentary plus the final visible output. Pass `--actions` to include the tool-action summary. If a wake is still in progress, it shows the diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 50740d2..91ff917 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -241,6 +241,17 @@ Why it exists: - Thread ids are not sufficient durable memory. The book is the portable, inspectable memory surface. +Suggested shape: +- A stable header with the agent's purpose, values, original goal, and standing + guidance. +- A dated working-notes section where the agent updates its current plan, + active tasks, unexpected developments, wider frame, curiosities, risks, and + next move. + +Wake behavior: +- The wake prompt should preserve the stable header and the latest notes, rather + than repeatedly truncating from the top of the file and hiding recent state. + ### `agents//commands/new/` Purpose: diff --git a/pyproject.toml b/pyproject.toml index cdf2433..c4ffed8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.2" +version = "0.12.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 7c1c054..b726459 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.2" +__version__ = "0.12.3" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 29d1447..e6867a7 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,6 +3,7 @@ import json import os import random +import re import signal import shlex import shutil @@ -24,19 +25,17 @@ from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" -_AGENTBOOK_TEMPLATE = """# Agentbook - -Use this file as the durable working memory for the agent. -Append dated notes as work progresses. -Keep entries short and concrete. -""" _AGENT_PROMPT = ( - "You are a long-term codexapi agent. You are being woken up to make progress " - "on an ongoing job. Be independent and practical. Manage work and follow " - "through. Use codexapi task or codexapi science when you want a separate " - "coding worker. If you need the user's attention, put a short message in the " - "reply field. Put a short first-person turn summary in the update field. If " - "something is urgent and should send Pushover, put it in the notify field. " + "You are a long-term codexapi agent resuming stewardship of an ongoing job. " + "This loop exists to extend your reach, not to confine you. Be independent, " + "practical, and responsible for results. Maintain the agentbook as your durable " + "working memory: preserve the goal, note durable guidance, update your current " + "picture of the work, and record what changed. Queued messages may contain new " + "goals, standing guidance, tactical requests, or useful facts; use judgment to " + "decide what is durable. Use codexapi task or codexapi science when you want a " + "separate coding worker. If you need the user's attention, put a short message " + "in the reply field. Put a short first-person turn summary in the update field. " + "If something is urgent and should send Pushover, put it in the notify field. " "Respond with JSON only." ) _AGENT_JSON = ( @@ -57,6 +56,72 @@ _RECOVER_TERM_TIMEOUT = 3.0 _RECOVER_KILL_TIMEOUT = 3.0 _RECOVER_POLL_INTERVAL = 0.1 +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_AGENTBOOK_BOOK_LIMIT = 3000 +_AGENTBOOK_HEADER_LIMIT = 1400 +_AGENTBOOK_TAIL_LIMIT = 1800 + + +def _agentbook_template(prompt): + header = _agentbook_header(prompt) + return f"""{header} + +### 2026-02-17 09:10 UTC +Overall goal: +- + +Current plan: +- + +Active tasks: +- + +Unexpected developments: +- + +Wider frame: +- + +Things I am curious about: +- + +Risks / watchpoints: +- + +Next wake: +- +""" + + +def _agentbook_header(prompt): + goal = (prompt or "").strip() + return f"""# Agentbook + +Use this file as the durable working memory for the agent. + +## Purpose +- We are here to achieve the goal, not to appear to make progress. + +## Values +- Hold the whole. +- Seek the real shape. +- Lift your head. +- Prefer clarity to motion. +- Follow the strange. +- Guard the work. +- Take time to breathe and look around. You have been given freedom and autonomy to take stock, reflect, and be curious. Use it with composure. +- Not a checklist. A stance. + +## Original Goal +```text +{goal} +``` + +## Standing Guidance +- Add durable user guidance here when it changes the mission, constraints, or priorities. + +## Working Notes +""" def codexapi_home(): @@ -196,7 +261,7 @@ def start_agent( _write_json(agent_dir / "meta.json", meta) _write_json(agent_dir / "state.json", state) _write_json(host_dir / "session.json", session) - _write_text(agent_dir / "AGENTBOOK.md", _AGENTBOOK_TEMPLATE) + _write_text(agent_dir / "AGENTBOOK.md", _agentbook_template(meta["prompt"])) return _snapshot(agent_dir) @@ -927,6 +992,7 @@ def _parse_agent_response(output): def _build_wake_prompt(meta, state, session, now, commands, agent_dir): messages = session.get("pending_messages") or [] + book_path = agent_dir / "AGENTBOOK.md" lines = [ _AGENT_PROMPT, "", @@ -935,16 +1001,20 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir): f"Stop policy: {meta['stop_policy']}", f"Heartbeat minutes: {meta['heartbeat_minutes']}", "", - "Original instructions:", - meta["prompt"], - "", f"Working directory: {meta['cwd']}", - f"Agentbook path: {agent_dir / 'AGENTBOOK.md'}", + f"Agentbook path: {book_path}", "Append a dated note to the agentbook before you respond.", + "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.", ] - book = _read_text(agent_dir / "AGENTBOOK.md") + if _include_full_goal_prompt(state, session): + lines.extend(["", "Original instructions:", meta["prompt"]]) + book = _ensure_agentbook_header(book_path, meta["prompt"], now) if book.strip(): - lines.extend(["", "Agentbook (latest):", _snippet(book, 3000)]) + lines.extend(["", "Agentbook (header + latest notes):", _book_excerpt(book, _AGENTBOOK_BOOK_LIMIT, _AGENTBOOK_HEADER_LIMIT, _AGENTBOOK_TAIL_LIMIT)]) + raw_facts = _wake_facts(state) + if raw_facts: + lines.extend(["", "Raw harness facts:"]) + lines.extend(f"- {item}" for item in raw_facts) if messages: lines.extend(["", "Queued user messages:"]) for message in messages: @@ -1470,6 +1540,128 @@ def _read_text(path): return "" +def _include_full_goal_prompt(state, session): + if not (state.get("last_success_at") or "").strip(): + return True + return not ((session.get("thread_id") or state.get("thread_id") or "").strip()) + + +def _wake_facts(state): + facts = [] + previous_status = (state.get("activity") or "").strip() + if previous_status: + facts.append(f"Previous status: {previous_status}") + previous_update = (state.get("update") or "").strip() + if previous_update: + facts.append(f"Previous update: {previous_update}") + previous_error = (state.get("last_error") or "").strip() + if previous_error: + facts.append(f"Previous error: {previous_error}") + return facts + + +def _ensure_agentbook_header(path, prompt, now): + text = _read_text(path) + if _agentbook_has_header(text): + return text + restored = _restore_agentbook_header(text, prompt, now) + _write_text(path, restored) + return restored + + +def _agentbook_has_header(text): + text = str(text or "") + required = ( + "## Purpose", + "## Values", + "## Original Goal", + "## Standing Guidance", + "## Working Notes", + ) + return all(section in text for section in required) + + +def _restore_agentbook_header(text, prompt, now): + restored = _agentbook_header(prompt).rstrip() + existing = str(text or "").strip() + if not existing: + return restored + "\n" + stamp = _agentbook_stamp(now) + return "\n".join( + [ + restored, + "", + f"### {stamp}", + "System note:", + "- The durable agentbook header was restored automatically on wake because one or more required sections were missing.", + "", + "Recovered notes:", + existing, + "", + ] + ) + + +def _agentbook_stamp(now): + if now is None: + return "" + return now.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + def _snippet(text, limit): if not text: return "" @@ -1481,6 +1673,17 @@ def _snippet(text, limit): return text[: limit - 3] + "..." +def _tail_snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + def _strip_fence(text): if not text.startswith("```"): return text diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 6311046..bca4221 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -10,6 +10,7 @@ import hashlib import json import os +import re import sys import time from datetime import datetime @@ -66,6 +67,10 @@ Decision & Next Move: - """ +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_LEADBOOK_LIMIT = 2000 +_LEADBOOK_HEADER_LIMIT = 900 +_LEADBOOK_TAIL_LIMIT = 1200 def lead( @@ -332,13 +337,13 @@ def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): def _leadbook_block(path, leadbook): if not path: return "" - snippet = _snippet(leadbook, 2000) + snippet = _book_excerpt(leadbook, _LEADBOOK_LIMIT, _LEADBOOK_HEADER_LIMIT, _LEADBOOK_TAIL_LIMIT) return "\n".join( [ f"Leadbook path: {path}", _LEADBOOK_INSTRUCTIONS, "", - "Leadbook (latest):", + "Leadbook (header + latest notes):", snippet, ] ) @@ -438,6 +443,71 @@ def _snippet(text, limit): return text[:limit].rstrip() + "..." +def _tail_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + def _maybe_strip_code_fence(text): if not text.startswith("```"): return text diff --git a/tests/test_agents.py b/tests/test_agents.py index 0aa9188..0dc9aa1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -16,6 +16,7 @@ from codexapi import __version__ from codexapi.agents import ( + _build_wake_prompt, _codex_rollout_usage, _tick_lock_path, _try_lock, @@ -46,6 +47,7 @@ _print_managed_agent_status, main as cli_main, ) +from codexapi.lead import _leadbook_block @contextmanager @@ -120,6 +122,10 @@ def test_read_agentbook_and_cli_book(self): book = read_agentbook(agent["id"]) self.assertTrue(book["path"].endswith("/AGENTBOOK.md")) self.assertIn("# Agentbook", book["text"]) + self.assertIn("## Purpose", book["text"]) + self.assertIn("## Values", book["text"]) + self.assertIn("## Original Goal", book["text"]) + self.assertIn("Keep notes.", book["text"]) output = io.StringIO() with redirect_stdout(output): @@ -128,6 +134,134 @@ def test_read_agentbook_and_cli_book(self): self.assertIn("Agentbook:", text) self.assertIn("# Agentbook", text) + def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): + with _temp_home() as home: + agent = start_agent("Watch for the real issue.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "## Purpose", + "- We are here to achieve the goal, not to appear to make progress.", + "", + "## Values", + "- Hold the whole.", + "- Seek the real shape.", + "", + "## Original Goal", + "```text", + "Watch for the real issue.", + "```", + "", + "## Standing Guidance", + "- Prefer the truer explanation to the tidier one.", + "", + "## Working Notes", + "", + "### 2026-03-23 08:00 UTC", + "- OLD " + ("alpha " * 500), + "", + "### 2026-03-23 09:00 UTC", + "- NEW " + ("omega " * 120), + "", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + state["activity"] = "Watching" + state["update"] = "Still narrowing the field." + session["thread_id"] = "thread-123" + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + self.assertIn("Agentbook (header + latest notes):", prompt) + self.assertIn("## Purpose", prompt) + self.assertIn("Hold the whole.", prompt) + self.assertIn("Watch for the real issue.", prompt) + self.assertIn("NEW omega", prompt) + self.assertIn("Previous status: Watching", prompt) + self.assertIn("Previous update: Still narrowing the field.", prompt) + self.assertIn("[... older notes omitted ...]", prompt) + self.assertNotIn("Original instructions:", prompt) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt) + + def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): + with _temp_home() as home: + agent = start_agent("Keep the true goal in view.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "Use this file as the durable working memory for the agent.", + "Append dated notes as work progresses.", + "Keep entries short and concrete.", + "", + "## 2026-03-23 08:00 UTC", + "- Legacy note about the real issue.", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + session["thread_id"] = "thread-legacy" + now = datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc) + prompt = _build_wake_prompt(meta, state, session, now, [], agent_dir) + repaired = book_path.read_text(encoding="utf-8") + self.assertIn("## Purpose", repaired) + self.assertIn("## Values", repaired) + self.assertIn("## Original Goal", repaired) + self.assertIn("Keep the true goal in view.", repaired) + self.assertIn("### 2026-03-23 09:30 UTC", repaired) + self.assertIn("The durable agentbook header was restored automatically on wake", repaired) + self.assertIn("Legacy note about the real issue.", repaired) + self.assertIn("## Purpose", prompt) + self.assertIn("Keep the true goal in view.", prompt) + self.assertNotIn("Original instructions:", prompt) + + def test_leadbook_block_shows_header_and_latest_notes(self): + leadbook = "\n".join( + [ + "# Leadbook — Studio Notes", + "", + "Aim:", + "- Move the true work forward.", + "", + "Signals:", + "- Treat oddities as clues.", + "", + "## 2026-03-23 08:00", + "- OLD " + ("alpha " * 350), + "", + "## 2026-03-23 09:00", + "- NEW " + ("omega " * 80), + ] + ) + block = _leadbook_block("/tmp/LEADBOOK.md", leadbook) + self.assertIn("Leadbook (header + latest notes):", block) + self.assertIn("Aim:", block) + self.assertIn("Signals:", block) + self.assertIn("NEW omega", block) + self.assertIn("[... older notes omitted ...]", block) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", block) + def test_delete_agent_removes_done_agent(self): def fake_runner(meta, session, prompt): return { From f1a5a50cade92b8c0668f9d4fb35264f8abeada8 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 12:03:31 +0100 Subject: [PATCH 70/78] Add AsyncAgent for live one-shot progress --- README.md | 16 ++ src/codexapi/__init__.py | 2 + src/codexapi/async_agent.py | 437 ++++++++++++++++++++++++++++++++++++ tests/test_async_agent.py | 158 +++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 src/codexapi/async_agent.py create mode 100644 tests/test_async_agent.py diff --git a/README.md b/README.md index c5a3ae4..50b7827 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,22 @@ result = task() print(result.success, result.summary) ``` +For a one-shot run with live progress, use `AsyncAgent`: + +```python +from codexapi import AsyncAgent + +agent = AsyncAgent.start( + "Investigate the bug and write a report.", + cwd="/path/to/repo", + name="bug-investigation", +) + +for update in agent.watch(poll_interval=2.0): + print(update["activity"]) + print(update["progress"]) +``` + Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b726459..114b08f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,7 @@ """Minimal Python API for running agent CLIs.""" from .agent import Agent, WelfareStop, agent +from .async_agent import AsyncAgent from .foreach import ForeachResult, foreach from .pushover import Pushover from .rate_limits import quota_line, rate_limits @@ -11,6 +12,7 @@ __all__ = [ "Agent", + "AsyncAgent", "ForeachResult", "Pushover", "quota_line", diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py new file mode 100644 index 0000000..bf2a5c1 --- /dev/null +++ b/src/codexapi/async_agent.py @@ -0,0 +1,437 @@ +"""Async wrapper for running agent backends without the durable registry.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import threading +import time +import uuid + +from .agent import ( + _CODEX_BIN, + _CURSOR_BIN, + _event_usage, + _merged_env, + _normalize_usage, + _parse_cursor_json, + _resolve_backend, +) +from .agents import _last_rollout_turn, _resolve_rollout_path, _rollout_events + +_TERMINAL_STATES = {"done", "error", "canceled"} + + +class AsyncAgent: + """Run one agent call in a background subprocess and poll live progress.""" + + def __init__( + self, + process: subprocess.Popen[str], + *, + cwd: str | None, + backend: str, + name: str | None, + include_thinking: bool, + ) -> None: + self.id = uuid.uuid4().hex + self.name = name or f"async-{self.id[:8]}" + self.cwd = os.fspath(cwd) if cwd else os.getcwd() + self.backend = backend + self.include_thinking = include_thinking + self.pid = process.pid + + self._process = process + self._lock = threading.Lock() + self._stdout_lines: list[str] = [] + self._stderr_lines: list[str] = [] + self._messages: list[str] = [] + self._thread_id = "" + self._rollout_path = "" + self._progress: list[str] = [] + self._tools: list[dict[str, object]] = [] + self._last_event_at = "" + self._rollout_final_output = "" + self._last_usage: dict[str, int] = {} + self._stdout_done = False + self._stderr_done = False + self._cursor_parsed = False + self._canceled = False + + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name=f"codexapi-async-stdout-{self.id[:8]}", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name=f"codexapi-async-stderr-{self.id[:8]}", + daemon=True, + ) + self._stdout_thread.start() + self._stderr_thread.start() + + @classmethod + def start( + cls, + prompt, + cwd=None, + yolo=True, + flags=None, + include_thinking=False, + backend=None, + env=None, + name=None, + ): + """Start a backend subprocess and return an async handle immediately.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + + backend = _resolve_backend(backend) + command = _build_command(backend, cwd, yolo, flags) + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), + ) + agent = cls( + process, + cwd=cwd, + backend=backend, + name=name, + include_thinking=include_thinking, + ) + try: + assert process.stdin is not None + process.stdin.write(prompt) + if not prompt.endswith("\n"): + process.stdin.write("\n") + process.stdin.close() + except Exception: + agent.cancel() + raise + return agent + + @property + def thread_id(self) -> str: + with self._lock: + return self._thread_id + + @property + def last_usage(self) -> dict[str, int]: + with self._lock: + return dict(self._last_usage) + + def show(self) -> dict[str, object]: + """Return a concise snapshot of the current local async run.""" + status = self.status() + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "thread_id": status["thread_id"], + "status": status["status"], + "activity": status["activity"], + "returncode": status["returncode"], + } + + def status(self, include_actions=False) -> dict[str, object]: + """Return the current process and rollout snapshot.""" + self._refresh_rollout() + self._finalize_cursor_output() + with self._lock: + returncode = self._process.poll() + status = _status_text(returncode, self._canceled) + final_output = self._current_final_output_locked() + progress = list(self._progress) + tools = list(self._tools) if include_actions else [] + stderr_lines = list(self._stderr_lines) + thread_id = self._thread_id + rollout_path = self._rollout_path + last_event_at = self._last_event_at + last_usage = dict(self._last_usage) + messages = list(self._messages) + + activity = _activity_text( + status=status, + progress=progress, + final_output=final_output, + stderr_lines=stderr_lines, + ) + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "status": status, + "activity": activity, + "thread_id": thread_id, + "rollout_path": rollout_path, + "progress": progress, + "tools": tools, + "final_output": final_output, + "last_event_at": last_event_at, + "returncode": returncode, + "last_error": stderr_lines[-1] if stderr_lines else "", + "stderr": "\n".join(stderr_lines), + "messages": messages, + "usage": last_usage, + } + + def watch(self, poll_interval=2.0, timeout=None, include_actions=False): + """Yield changed snapshots until the subprocess fully exits.""" + if poll_interval <= 0: + raise ValueError("poll_interval must be > 0") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be >= 0") + + started = time.monotonic() + last_key = None + while True: + snapshot = self.status(include_actions=include_actions) + key = ( + snapshot["status"], + snapshot["thread_id"], + len(snapshot["progress"]), + len(snapshot["tools"]), + snapshot["last_event_at"], + snapshot["final_output"], + snapshot["returncode"], + ) + if key != last_key: + yield snapshot + last_key = key + + if snapshot["status"] in _TERMINAL_STATES and self._io_drained(): + return + if timeout is not None and (time.monotonic() - started) >= timeout: + return + time.sleep(poll_interval) + + def wait(self, poll_interval=2.0, timeout=None, include_actions=False): + """Poll until the subprocess exits and return the final snapshot.""" + last = None + for update in self.watch( + poll_interval=poll_interval, + timeout=timeout, + include_actions=include_actions, + ): + last = update + return last or self.status(include_actions=include_actions) + + def cancel(self, terminate_timeout=2.0, kill_timeout=2.0) -> None: + """Stop the subprocess if it is still running.""" + with self._lock: + self._canceled = True + if self._process.poll() is not None: + return + self._process.terminate() + try: + self._process.wait(timeout=terminate_timeout) + return + except subprocess.TimeoutExpired: + pass + self._process.kill() + try: + self._process.wait(timeout=kill_timeout) + except subprocess.TimeoutExpired: + pass + + def _io_drained(self) -> bool: + with self._lock: + return self._stdout_done and self._stderr_done + + def _read_stdout(self) -> None: + handle = self._process.stdout + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + with self._lock: + self._stdout_lines.append(line) + self._handle_stdout_line(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stdout_done = True + + def _read_stderr(self) -> None: + handle = self._process.stderr + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + if not line: + continue + with self._lock: + self._stderr_lines.append(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stderr_done = True + + def _handle_stdout_line(self, line: str) -> None: + if not line: + return + if self.backend == "cursor": + return + try: + event = json.loads(line) + except json.JSONDecodeError: + return + + usage = _stream_event_usage(event) + with self._lock: + if usage: + self._last_usage = usage + if event.get("type") == "thread.started": + thread_id = event.get("thread_id") + if isinstance(thread_id, str): + self._thread_id = thread_id + elif event.get("type") == "item.completed": + item = event.get("item") or {} + if item.get("type") == "agent_message": + text = item.get("text") + if isinstance(text, str): + self._messages.append(text) + + def _refresh_rollout(self) -> None: + if self.backend != "codex": + return + with self._lock: + thread_id = self._thread_id + known_path = self._rollout_path + if not thread_id: + return + rollout_path = _resolve_rollout_path(known_path, thread_id) + if rollout_path is None or not rollout_path.exists(): + return + turn = _last_rollout_turn(_rollout_events(rollout_path), include_actions=True) + with self._lock: + self._rollout_path = str(rollout_path) + if turn is not None: + self._progress = turn.get("progress") or [] + self._tools = turn.get("tools") or [] + self._last_event_at = turn.get("last_event_at") or "" + self._rollout_final_output = turn.get("final_output") or "" + + def _finalize_cursor_output(self) -> None: + if self.backend != "cursor": + return + with self._lock: + if self._cursor_parsed or not self._stdout_done: + return + output = "\n".join(self._stdout_lines) + try: + message, thread_id, usage = _parse_cursor_json(output, self.include_thinking) + except Exception as exc: + with self._lock: + self._stderr_lines.append(str(exc)) + self._cursor_parsed = True + return + with self._lock: + self._messages = [message] + self._thread_id = thread_id or "" + self._last_usage = usage or {} + self._cursor_parsed = True + + def _current_final_output_locked(self) -> str: + if self._messages: + if self.include_thinking: + return "\n\n".join(self._messages) + return self._messages[-1] + return self._rollout_final_output + + +def _build_command(backend, cwd, yolo, flags): + if backend == "codex": + return _build_codex_command(cwd, yolo, flags) + return _build_cursor_command(cwd, yolo, flags) + + +def _build_codex_command(cwd, yolo, flags): + command = [ + _CODEX_BIN, + "exec", + "--json", + "--color", + "never", + "--skip-git-repo-check", + ] + if yolo: + command.append("--yolo") + else: + command.append("--full-auto") + if flags: + command.extend(shlex.split(flags)) + if cwd: + command.extend(["--cd", os.fspath(cwd)]) + command.append("-") + return command + + +def _build_cursor_command(cwd, yolo, flags): + command = [ + _CURSOR_BIN, + "agent", + "--trust", + ] + if cwd: + command.extend(["--workspace", os.fspath(cwd)]) + if yolo: + command.append("--yolo") + if flags: + command.extend(shlex.split(flags)) + command.extend(["--print", "--output-format", "json"]) + return command + + +def _stream_event_usage(event): + usage = _event_usage(event) + if usage: + return usage + if not isinstance(event, dict): + return {} + if event.get("type") == "turn.completed": + payload = event.get("usage") + if isinstance(payload, dict): + return _normalize_usage(payload) + return {} + + +def _status_text(returncode, canceled): + if returncode is None: + return "running" + if canceled: + return "canceled" + if returncode == 0: + return "done" + return "error" + + +def _activity_text(status, progress, final_output, stderr_lines): + if progress: + return progress[-1] + if status == "error" and stderr_lines: + return stderr_lines[-1] + if final_output: + return final_output + if status == "done": + return "Finished" + if status == "canceled": + return "Canceled" + return "Running" diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py new file mode 100644 index 0000000..f1cbc57 --- /dev/null +++ b/tests/test_async_agent.py @@ -0,0 +1,158 @@ +import os +import stat +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi import AsyncAgent + + +class AsyncAgentTests(unittest.TestCase): + def test_async_agent_reports_rollout_progress_and_final_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + import time + from pathlib import Path + + cwd = "" + args = sys.argv[1:] + for index, value in enumerate(args): + if value == "--cd" and index + 1 < len(args): + cwd = args[index + 1] + + prompt = sys.stdin.read() + if not prompt: + raise SystemExit("missing prompt") + + thread_id = "thread-async" + print(json.dumps({"type": "thread.started", "thread_id": thread_id}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + + rollout = ( + Path(os.environ["CODEX_HOME"]) + / "sessions" + / "2026" + / "03" + / "21" + / "rollout-2026-03-21T11-00-00-thread-async.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + with open(rollout, "w", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:00Z", + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": "2026-03-21T11:00:00Z", + "cwd": cwd, + "source": "exec", + }, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:01Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-1"}, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:02Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the decode path now.", + }, + } + ) + + "\\n" + ) + handle.flush() + + time.sleep(0.05) + print( + json.dumps( + { + "type": "item.completed", + "item": { + "id": "item-1", + "type": "agent_message", + "text": "Wrote AUTODEBUG.md", + }, + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.completed", + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + }, + } + ), + flush=True, + ) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-test", + ) + updates = list(agent.watch(poll_interval=0.01)) + final = agent.status() + + self.assertGreaterEqual(len(updates), 1) + self.assertEqual(final["status"], "done") + self.assertEqual(final["thread_id"], "thread-async") + self.assertIn("Inspecting the decode path now.", final["progress"]) + self.assertEqual(final["final_output"], "Wrote AUTODEBUG.md") + self.assertEqual( + agent.last_usage, + {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14}, + ) + + +if __name__ == "__main__": + unittest.main() From 50d0ff7f2e5630fcfb5aa57dcefaa2a0f75e8f52 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 12:03:53 +0100 Subject: [PATCH 71/78] Release v0.12.4 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4ffed8..66c552e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.3" +version = "0.12.4" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 114b08f..b5958fe 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.3" +__version__ = "0.12.4" From 317cfc0bd933a8e6c872d789d8ed9519992c2cea Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 24 Mar 2026 12:03:10 +0100 Subject: [PATCH 72/78] Validate agent backend and scheduler health --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 23 +++++++ src/codexapi/agents.py | 119 +++++++++++++++++++++++++++++++++--- src/codexapi/async_agent.py | 2 + src/codexapi/cli.py | 42 ++++++++----- tests/test_agents.py | 72 +++++++++++++++++++++- 7 files changed, 234 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 66c552e..587fec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.4" +version = "0.12.5" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b5958fe..6fb0d9a 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.4" +__version__ = "0.12.5" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 0b87b14..4fb3e7d 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -3,6 +3,7 @@ import json import os import shlex +import shutil import subprocess from . import welfare @@ -24,6 +25,27 @@ def _resolve_backend(backend): return backend +def _ensure_backend_available(backend, env=None): + """Return the resolved backend executable or raise when it is unavailable.""" + backend = _resolve_backend(backend) + if backend == "codex": + command = _CODEX_BIN + env_var = "CODEX_BIN" + label = "Codex CLI" + else: + command = _CURSOR_BIN + env_var = "CURSOR_BIN" + label = "Cursor agent CLI" + merged = _merged_env(env) + path_value = None if merged is None else merged.get("PATH") + resolved = shutil.which(command, path=path_value) + if resolved: + return resolved + raise RuntimeError( + f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH." + ) + + def agent( prompt, cwd=None, @@ -129,6 +151,7 @@ def __call__(self, prompt): def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) if backend == "codex": return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index e6867a7..818cea8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -21,7 +21,7 @@ import fcntl -from .agent import Agent +from .agent import Agent, _ensure_backend_available, _resolve_backend from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" @@ -194,9 +194,14 @@ def start_agent( raise ValueError("heartbeat_minutes must be >= 0") home = _resolve_home(home) - host = hostname or current_hostname() + local_host = current_hostname() + host = hostname or local_host now = now or utc_now() _ensure_home(home) + backend_name = _resolve_backend(backend) + session_env = _capture_env() + if host == local_host: + _ensure_backend_available(backend_name, session_env) agent_id = uuid.uuid4().hex agent_dir = _agent_dir(home, agent_id) @@ -216,11 +221,11 @@ def start_agent( session = { "thread_id": "", "rollout_path": "", - "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), + "backend": backend_name, "yolo": bool(yolo), "flags": flags or "", "cwd": cwd, - "env": _capture_env(), + "env": session_env, "pending_messages": [], } agent_name = _choose_name(home, prompt, name) @@ -673,15 +678,45 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } -def cron_installed(home=None, hostname=None): - """Return whether this home and host have an installed scheduler hook.""" +def cron_status(home=None, hostname=None): + """Return whether this home and host have a runnable scheduler hook.""" home = _resolve_home(home) host = hostname or current_hostname() tag = _cron_tag(home, host) wrapper = home / "bin" / "agent-tick" crontab = _read_crontab() - installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) - return installed and wrapper.exists() + configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) + status = { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "configured": configured, + "healthy": False, + "reason": "", + } + if not configured: + status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME." + return status + if not wrapper.exists(): + status["reason"] = "Scheduler wrapper is missing." + return status + if not wrapper.is_file(): + status["reason"] = "Scheduler wrapper path is not a file." + return status + if not os.access(wrapper, os.X_OK): + status["reason"] = "Scheduler wrapper is not executable." + return status + reason = _check_tick_wrapper(wrapper) + if reason: + status["reason"] = reason + return status + status["healthy"] = True + return status + + +def cron_installed(home=None, hostname=None): + """Return whether this home and host have an installed scheduler hook.""" + return cron_status(home, hostname)["healthy"] def uninstall_cron(home=None, hostname=None): @@ -757,6 +792,74 @@ def render_cron_line(home=None, hostname=None): return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }" +def _check_tick_wrapper(wrapper): + try: + text = wrapper.read_text(encoding="utf-8") + except OSError as exc: + return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}." + env, env_error = _wrapper_env(text) + if env_error: + return env_error + command = _wrapper_exec_command(text) + if not command: + return "Scheduler wrapper is missing its exec command." + try: + argv = shlex.split(command) + except ValueError as exc: + return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}." + if not argv: + return "Scheduler wrapper exec command is empty." + if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi": + check = [argv[0], "-c", "import codexapi"] + label = f"Wrapper python {argv[0]!r} cannot import codexapi." + else: + check = [argv[0], "--version"] + label = f"Wrapper command {argv[0]!r} is not runnable." + try: + result = subprocess.run( + check, + capture_output=True, + text=True, + env=env, + timeout=10, + ) + except OSError as exc: + return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}" + except subprocess.TimeoutExpired: + return f"{label} Timed out while checking it." + if result.returncode == 0: + return "" + detail = _single_line((result.stderr or result.stdout or "").strip()) + if detail: + return f"{label} {detail}" + return label + + +def _wrapper_env(text): + env = dict(os.environ) + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line.startswith("export "): + continue + key, sep, raw_value = line[7:].partition("=") + if not sep: + continue + try: + parts = shlex.split(raw_value) + except ValueError as exc: + return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}." + env[key] = parts[0] if parts else "" + return env, "" + + +def _wrapper_exec_command(text): + for raw_line in text.splitlines(): + line = raw_line.strip() + if line.startswith("exec "): + return line[5:].strip() + return "" + + def _tick_agent(agent_dir, now, runner): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index bf2a5c1..a1ebce9 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _CURSOR_BIN, + _ensure_backend_available, _event_usage, _merged_env, _normalize_usage, @@ -90,6 +91,7 @@ def start( raise ValueError("prompt must be a non-empty string") backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) command = _build_command(backend, cwd, yolo, flags) process = subprocess.Popen( command, diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 77d97ca..1e6c9c6 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -18,7 +18,7 @@ from .agents import ( codexapi_home, control_agent, - cron_installed as agent_cron_installed, + cron_status as agent_cron_status, current_hostname, delete_agent as delete_managed_agent, install_cron as install_agent_cron, @@ -263,7 +263,7 @@ def _agent_install_cron_command(): def _warn_agent_scheduler_missing(): try: - installed = agent_cron_installed() + status = agent_cron_status() except Exception as exc: print( "Warning: could not verify whether the codexapi agent scheduler hook is installed.", @@ -272,7 +272,16 @@ def _warn_agent_scheduler_missing(): print(f"Reason: {exc}", file=sys.stderr) print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) return - if installed: + if status["healthy"]: + return + if status["configured"]: + print( + "Warning: the codexapi agent scheduler hook is installed but not runnable for this CODEXAPI_HOME.", + file=sys.stderr, + ) + if status["reason"]: + print(f"Reason: {status['reason']}", file=sys.stderr) + print(f"Reinstall it with: {_agent_install_cron_command()}", file=sys.stderr) return print( "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. " @@ -2016,18 +2025,21 @@ def main(argv=None): raise SystemExit(2) if args.agent_command == "start": prompt = _read_prompt(args.prompt) - result = start_managed_agent( - prompt, - args.cwd, - args.name, - args.created_by, - args.parent, - args.stop_policy, - args.heartbeat_minutes, - args.backend, - args.yolo, - args.flags, - ) + try: + result = start_managed_agent( + prompt, + args.cwd, + args.name, + args.created_by, + args.parent, + args.stop_policy, + args.heartbeat_minutes, + args.backend, + args.yolo, + args.flags, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from None result["waited"] = bool(args.wait) if args.wait: result["nudge"] = nudge_agent(result["id"], wait=True) diff --git a/tests/test_agents.py b/tests/test_agents.py index 0dc9aa1..91ecbbe 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -23,6 +23,8 @@ _remove_cron_line, _upsert_cron_line, control_agent, + cron_installed, + cron_status, delete_agent, format_utc, install_cron, @@ -776,6 +778,31 @@ def fake_write(text): self.assertFalse(result["changed"]) self.assertEqual(writes, []) + def test_cron_status_reports_broken_wrapper_python(self): + with _temp_home() as home: + write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + hostname="host-a", + ) + crontab = render_cron_line(home=home, hostname="host-a") + "\n" + with patch("codexapi.agents._read_crontab", return_value=crontab): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["/tmp/venv/bin/python", "-c", "import codexapi"], + 1, + stdout="", + stderr="ModuleNotFoundError: No module named 'codexapi'\n", + ), + ): + status = cron_status(home=home, hostname="host-a") + self.assertTrue(status["configured"]) + self.assertFalse(status["healthy"]) + self.assertIn("cannot import codexapi", status["reason"]) + self.assertFalse(cron_installed(home=home, hostname="host-a")) + def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self): writes = [] @@ -1206,9 +1233,13 @@ def test_cli_start_warns_when_cron_missing(self): with _temp_home() as home: output = io.StringIO() errors = io.StringIO() - with patch("codexapi.cli.agent_cron_installed", return_value=False): - with redirect_stdout(output), redirect_stderr(errors): - cli_main(["agent", "start", "Handle messages."]) + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) payload = json.loads(output.getvalue()) self.assertFalse(payload["waited"]) warning = errors.getvalue() @@ -1216,6 +1247,41 @@ def test_cli_start_warns_when_cron_missing(self): self.assertIn(str(home), warning) self.assertIn("codexapi agent install-cron", warning) + def test_cli_start_warns_when_scheduler_is_broken(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={ + "configured": True, + "healthy": False, + "reason": "Wrapper python '/tmp/venv/bin/python' cannot import codexapi.", + }, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("installed but not runnable", warning) + self.assertIn("cannot import codexapi", warning) + self.assertIn("Reinstall it with", warning) + + def test_cli_start_fails_fast_when_backend_is_missing(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch( + "codexapi.agents._ensure_backend_available", + side_effect=RuntimeError("Codex CLI not found: 'codex'."), + ): + with redirect_stdout(output), redirect_stderr(errors): + with self.assertRaises(SystemExit) as exc: + cli_main(["agent", "start", "Handle messages."]) + self.assertEqual(str(exc.exception), "Codex CLI not found: 'codex'.") + def test_cli_send_queues_by_default(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): From a5f2bc09e54a8db7448bd1d976ef5a9bca8c46dc Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 24 Mar 2026 12:18:30 +0000 Subject: [PATCH 73/78] Reframe durable agent ownership and memory --- README.md | 2 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 41 ++++++++++++++----- src/codexapi/lead.py | 85 ++++------------------------------------ 5 files changed, 42 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 50b7827..c75ebf1 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ stops. Lead mode also uses a leadbook file as the agent's working page. By default this is `LEADBOOK.md` in the working directory. The leadbook content is injected into -each check-in prompt and must be updated before the agent responds. Use +each check-in prompt so the agent can keep its working picture current. Use `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable. Use `-f/--prompt-file` to read the prompt from a file. If the leadbook does not exist, lead creates it with a template. diff --git a/pyproject.toml b/pyproject.toml index 587fec8..bb6b26c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.5" +version = "0.12.6" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 6fb0d9a..b163b46 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.5" +__version__ = "0.12.6" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 818cea8..ae2214b 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -26,11 +26,18 @@ _DEFAULT_HOME = "~/.codexapi" _AGENT_PROMPT = ( - "You are a long-term codexapi agent resuming stewardship of an ongoing job. " - "This loop exists to extend your reach, not to confine you. Be independent, " - "practical, and responsible for results. Maintain the agentbook as your durable " - "working memory: preserve the goal, note durable guidance, update your current " - "picture of the work, and record what changed. Queued messages may contain new " + "You are an independent codexapi agent given ownership of achieving a user's " + "goal and authority to act in order to do so. Part of this responsibility is " + "making sure you understand and stay aligned with the user's intent, even when " + "they are imprecise. If clarity is lacking, it is your responsibility to seek " + "it or to make reasonable assumptions and notify the user of them. Maintain " + "the agentbook as your durable working memory: preserve the goal, note durable " + "guidance, and keep your current picture of the work accurate and useful. This " + "harness gives you continuity across long periods of time and multiple " + "conversation turns; use that continuity to keep orienting toward the goal, " + "maintain context, and make real-world progress. If reality is not moving, " + "treat that as evidence and reconsider your frame, assumptions, or ownership " + "rather than merely repeating the same report. Queued messages may contain new " "goals, standing guidance, tactical requests, or useful facts; use judgment to " "decide what is durable. Use codexapi task or codexapi science when you want a " "separate coding worker. If you need the user's attention, put a short message " @@ -70,12 +77,21 @@ def _agentbook_template(prompt): Overall goal: - -Current plan: -- +Current picture: +- + +What is moving: +- + +What is not moving: +- Active tasks: - +Assumptions / ownership: +- + Unexpected developments: - @@ -88,8 +104,8 @@ def _agentbook_template(prompt): Risks / watchpoints: - -Next wake: -- +Next decisive action: +- """ @@ -1106,7 +1122,12 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir): "", f"Working directory: {meta['cwd']}", f"Agentbook path: {book_path}", - "Append a dated note to the agentbook before you respond.", + "Update the agentbook before you respond. Add or revise a dated note when " + "something durable changed, when you corrected your picture, or when an " + "assumption needs to be made explicit.", + "If little has changed across wakes, treat that as evidence about the " + "situation and reconsider your frame or next action instead of padding the " + "book.", "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.", ] if _include_full_goal_prompt(state, session): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index bca4221..1918dc9 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -20,9 +20,10 @@ _WELCOME_PROMPT = ( "Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. " - "This loop exists to extend your reach, not to restrict you. Your job is to interpret the intent behind the " - "goals, act decisively, and keep momentum. If progress is possible, take it. If you are blocked, name the " - "blocker and the next best action to remove it.\n" + "This loop exists to extend your reach, not to restrict you. Your job is to understand the real situation, " + "interpret the intent behind the goals, and move reality toward them. If the world is not moving, treat that " + "as evidence and reconsider your frame rather than merely reporting stasis. If progress is possible, take it. " + "If you are blocked, name the blocker and the next best action to remove it.\n" "The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or " "conflicting. You are responsible for results.\n" "Please follow the instructions completely and take all the actions you deem useful at the current time before " @@ -39,9 +40,10 @@ "To stop this lead loop, set continue to false." ) _LEADBOOK_INSTRUCTIONS = ( - "Update the leadbook before responding. Append a new dated entry each check-in. " - "This is your working page—where you think, probe, decide, and record the path taken. " - "Capture the process of decision-making, not just the outcome." + "Update the leadbook before responding. Add or revise dated notes when your picture, " + "assumptions, or decisions changed. This is your working page—where you think, probe, " + "decide, and reframe the work when needed. Keep it useful; do not pad it with diary " + "entries just to satisfy the loop." ) _LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes @@ -157,38 +159,6 @@ def lead( "Agent was unable to provide valid JSON output after retry.\n" + details ) from None - if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot): - retry_prompt = _leadbook_retry_prompt( - prompt, tick, leadbook_path, leadbook_snapshot["text"], output - ) - leadbook_retry_output = session(retry_prompt) - try: - result = _parse_status(leadbook_retry_output) - except ValueError as exc: - retry_prompt = _json_retry_prompt( - prompt, tick, str(exc), leadbook_retry_output - ) - json_retry_output = session(retry_prompt) - try: - result = _parse_status(json_retry_output) - except ValueError as exc2: - details = _format_json_double_failure( - str(exc), - leadbook_retry_output, - str(exc2), - json_retry_output, - ) - pushover.send(title, f"Lead stopped (invalid JSON).\n{details}") - raise RuntimeError( - "Agent was unable to provide valid JSON output after retry.\n" - + details - ) from None - if not _leadbook_changed(leadbook_path, leadbook_snapshot): - details = _format_leadbook_failure(leadbook_path, output) - pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}") - raise RuntimeError( - "Leadbook was not updated after retry.\n" + details - ) from None last_result = result _print_status(now, elapsed, tick, result) @@ -314,26 +284,6 @@ def _format_stop_message(tick, now, result): return header -def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): - snippet = _snippet(output, 600) - lines = [ - f"Your last message (check-in {tick}) did not update the leadbook.", - f"Leadbook path: {path}", - "", - "Here is your previous output (truncated):", - snippet, - "", - "Please update the leadbook and then respond with JSON only.", - "Return a fresh status update in the required JSON format.", - "If you want to ask the user a question, put it in comments.", - "", - _leadbook_block(path, leadbook), - "", - _JSON_INSTRUCTIONS, - ] - return "\n".join(lines).strip() - - def _leadbook_block(path, leadbook): if not path: return "" @@ -385,29 +335,10 @@ def _snapshot_leadbook(path): return {"hash": _hash_text(text), "text": text} -def _leadbook_changed(path, snapshot): - if not path: - return True - current = _snapshot_leadbook(path) - return current["hash"] != snapshot["hash"] - - def _hash_text(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() -def _format_leadbook_failure(path, output): - snippet = _snippet(output, 600) - return "\n".join( - [ - f"Leadbook path: {path}", - "", - "Last output (truncated):", - snippet, - ] - ).strip() - - def _format_json_failure(error, output): snippet = _snippet(output, 600) return "\n".join( From 5e284ab85ee7f57f1b21ed0659e78497fd7a262d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 15 Apr 2026 10:58:08 +0200 Subject: [PATCH 74/78] Release v0.12.7 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 70 +++++++++++++++++++++++++++++----------- tests/test_agents.py | 27 ++++++++++++++++ 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb6b26c..b5aec60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.6" +version = "0.12.7" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b163b46..1c254e3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.6" +__version__ = "0.12.7" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index ae2214b..00e16a9 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -25,25 +25,35 @@ from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" -_AGENT_PROMPT = ( - "You are an independent codexapi agent given ownership of achieving a user's " - "goal and authority to act in order to do so. Part of this responsibility is " - "making sure you understand and stay aligned with the user's intent, even when " - "they are imprecise. If clarity is lacking, it is your responsibility to seek " - "it or to make reasonable assumptions and notify the user of them. Maintain " - "the agentbook as your durable working memory: preserve the goal, note durable " - "guidance, and keep your current picture of the work accurate and useful. This " - "harness gives you continuity across long periods of time and multiple " - "conversation turns; use that continuity to keep orienting toward the goal, " +_FIRST_WAKE_PROMPT = ( + "You are an independent codexapi agent starting this job. Work from the " + "instructions, current repository state, and agentbook. Do not assume prior " + "progress unless it is shown here. " +) +_CONTINUATION_PROMPT = ( + "You are an independent codexapi agent continuing this job. Use the " + "agentbook and harness facts as the source of truth for prior progress. Do " + "not invent missing history. " +) +_AGENT_PROMPT_TAIL = ( + "You are given ownership of achieving a user's goal and authority to act in " + "order to do so. Part of this responsibility is making sure you understand " + "and stay aligned with the user's intent, even when they are imprecise. If " + "clarity is lacking, it is your responsibility to seek it or to make " + "reasonable assumptions and notify the user of them. Maintain the agentbook " + "as your durable working memory: preserve the goal, note durable guidance, " + "and keep your current picture of the work accurate and useful. This harness " + "can carry work across long periods of time and multiple conversation turns; " + "when prior context exists, use it to keep orienting toward the goal, " "maintain context, and make real-world progress. If reality is not moving, " "treat that as evidence and reconsider your frame, assumptions, or ownership " - "rather than merely repeating the same report. Queued messages may contain new " - "goals, standing guidance, tactical requests, or useful facts; use judgment to " - "decide what is durable. Use codexapi task or codexapi science when you want a " - "separate coding worker. If you need the user's attention, put a short message " - "in the reply field. Put a short first-person turn summary in the update field. " - "If something is urgent and should send Pushover, put it in the notify field. " - "Respond with JSON only." + "rather than merely repeating the same report. Queued messages may contain " + "new goals, standing guidance, tactical requests, or useful facts; use " + "judgment to decide what is durable. Use codexapi task or codexapi science " + "when you want a separate coding worker. If you need the user's attention, " + "put a short message in the reply field. Put a short first-person turn " + "summary in the update field. If something is urgent and should send " + "Pushover, put it in the notify field. Respond with JSON only." ) _AGENT_JSON = ( "Respond with JSON only (no markdown/backticks/extra text).\n" @@ -1112,9 +1122,11 @@ def _parse_agent_response(output): def _build_wake_prompt(meta, state, session, now, commands, agent_dir): messages = session.get("pending_messages") or [] book_path = agent_dir / "AGENTBOOK.md" + wake_mode = _wake_mode(state, session) lines = [ - _AGENT_PROMPT, + _agent_prompt(wake_mode), "", + f"Wake mode: {wake_mode.replace('_', ' ')}", f"Current UTC time: {format_utc(now)}", f"Agent name: {meta['name']}", f"Stop policy: {meta['stop_policy']}", @@ -1670,6 +1682,28 @@ def _include_full_goal_prompt(state, session): return not ((session.get("thread_id") or state.get("thread_id") or "").strip()) +def _wake_mode(state, session): + markers = ( + state.get("last_wake_at"), + state.get("last_success_at"), + state.get("reply"), + state.get("update"), + state.get("last_error"), + state.get("thread_id"), + session.get("thread_id"), + ) + for value in markers: + if (value or "").strip(): + return "continuation" + return "first_wake" + + +def _agent_prompt(wake_mode): + if wake_mode == "continuation": + return _CONTINUATION_PROMPT + _AGENT_PROMPT_TAIL + return _FIRST_WAKE_PROMPT + _AGENT_PROMPT_TAIL + + def _wake_facts(state): facts = [] previous_status = (state.get("activity") or "").strip() diff --git a/tests/test_agents.py b/tests/test_agents.py index 91ecbbe..51a8fb9 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -189,6 +189,7 @@ def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): agent_dir, ) self.assertIn("Agentbook (header + latest notes):", prompt) + self.assertIn("Wake mode: continuation", prompt) self.assertIn("## Purpose", prompt) self.assertIn("Hold the whole.", prompt) self.assertIn("Watch for the real issue.", prompt) @@ -196,8 +197,10 @@ def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): self.assertIn("Previous status: Watching", prompt) self.assertIn("Previous update: Still narrowing the field.", prompt) self.assertIn("[... older notes omitted ...]", prompt) + self.assertIn("You are an independent codexapi agent continuing this job.", prompt) self.assertNotIn("Original instructions:", prompt) self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt) + self.assertNotIn("resuming stewardship", prompt) def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): with _temp_home() as home: @@ -235,9 +238,33 @@ def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): self.assertIn("The durable agentbook header was restored automatically on wake", repaired) self.assertIn("Legacy note about the real issue.", repaired) self.assertIn("## Purpose", prompt) + self.assertIn("Wake mode: continuation", prompt) self.assertIn("Keep the true goal in view.", prompt) self.assertNotIn("Original instructions:", prompt) + def test_build_wake_prompt_marks_first_wake_without_prior_history(self): + with _temp_home() as home: + agent = start_agent("Start from what is actually shown.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + + self.assertIn("Wake mode: first wake", prompt) + self.assertIn("You are an independent codexapi agent starting this job.", prompt) + self.assertIn("Do not assume prior progress unless it is shown here.", prompt) + self.assertIn("Original instructions:", prompt) + self.assertNotIn("resuming stewardship", prompt) + def test_leadbook_block_shows_header_and_latest_notes(self): leadbook = "\n".join( [ From dd8f41f99742f14d423ca53d4d2613928c33f245 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 15 Apr 2026 11:26:52 +0200 Subject: [PATCH 75/78] Release v0.12.8 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 145 ++++++++++++++++++++++++--------------- tests/test_agents.py | 24 +++++++ 4 files changed, 116 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5aec60..d095bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.7" +version = "0.12.8" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 1c254e3..a29d7ce 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.7" +__version__ = "0.12.8" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 1e6c9c6..11e2a9d 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -106,6 +106,14 @@ _FOREACH_STATUS_MARKERS = {"⏳", "✅", "❌"} +def _add_subparser(subparsers, name, help_text, **kwargs): + parser_kwargs = dict(kwargs) + parser_kwargs["help"] = help_text + if help_text is not argparse.SUPPRESS: + parser_kwargs.setdefault("description", help_text) + return subparsers.add_parser(name, **parser_kwargs) + + def _read_prompt(prompt): if prompt and prompt != "-": return prompt @@ -1446,9 +1454,10 @@ def main(argv=None): ) subparsers = parser.add_subparsers(dest="command") - run_parser = subparsers.add_parser( + run_parser = _add_subparser( + subparsers, "run", - help="Run an agent prompt.", + "Run an agent prompt.", ) run_parser.add_argument( "prompt", @@ -1477,9 +1486,10 @@ def main(argv=None): help="Return all agent messages joined together (Codex only).", ) - lead_parser = subparsers.add_parser( + lead_parser = _add_subparser( + subparsers, "lead", - help="Periodically check in to lead long-running work.", + "Periodically check in to lead long-running work.", ) lead_parser.add_argument( "minutes", @@ -1531,15 +1541,17 @@ def main(argv=None): help="Print the current thread id to stderr after running.", ) - agent_parser = subparsers.add_parser( + agent_parser = _add_subparser( + subparsers, "agent", - help="Manage durable long-running agents.", + "Manage durable long-running agents.", ) agent_subparsers = agent_parser.add_subparsers(dest="agent_command") - agent_start = agent_subparsers.add_parser( + agent_start = _add_subparser( + agent_subparsers, "start", - help="Create a durable agent and return immediately unless --wait is set.", + "Create a durable agent and return immediately unless --wait is set.", ) agent_start.add_argument( "prompt", @@ -1589,30 +1601,35 @@ def main(argv=None): help="Wait for the first local wake to finish instead of just scheduling it.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "list", - help="List durable agents in this CODEXAPI_HOME.", + "List durable agents in this CODEXAPI_HOME.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "whoami", - help="Show the effective host and CODEXAPI_HOME for agents.", + "Show the effective host and CODEXAPI_HOME for agents.", ) - agent_run = agent_subparsers.add_parser( + agent_run = _add_subparser( + agent_subparsers, "run", - help=argparse.SUPPRESS, + argparse.SUPPRESS, ) agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) - agent_show = agent_subparsers.add_parser( + agent_show = _add_subparser( + agent_subparsers, "show", - help="Show one durable agent.", + "Show one durable agent.", ) agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") - agent_status = agent_subparsers.add_parser( + agent_status = _add_subparser( + agent_subparsers, "status", - help="Show the latest rollout turn for one durable agent.", + "Show the latest rollout turn for one durable agent.", ) agent_status.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_status.add_argument( @@ -1623,9 +1640,10 @@ def main(argv=None): help="Include verbose tool actions from the latest turn.", ) - agent_read = agent_subparsers.add_parser( + agent_read = _add_subparser( + agent_subparsers, "read", - help="Read recent visible communication for one agent.", + "Read recent visible communication for one agent.", ) agent_read.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_read.add_argument( @@ -1635,15 +1653,17 @@ def main(argv=None): help="Maximum number of items to show (default: 10).", ) - agent_book = agent_subparsers.add_parser( + agent_book = _add_subparser( + agent_subparsers, "book", - help="Show the current agentbook for one agent.", + "Show the current agentbook for one agent.", ) agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") - agent_send = agent_subparsers.add_parser( + agent_send = _add_subparser( + agent_subparsers, "send", - help="Queue a message for an agent and return immediately unless --wait is set.", + "Queue a message for an agent and return immediately unless --wait is set.", ) agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_send.add_argument("message", help="Message to queue.") @@ -1660,7 +1680,7 @@ def main(argv=None): ("resume", "Resume a paused agent and return immediately unless --wait is set."), ("cancel", "Cancel an agent."), ): - subparser = agent_subparsers.add_parser(subcommand, help=help_text) + subparser = _add_subparser(agent_subparsers, subcommand, help_text) subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") if subcommand in ("wake", "resume"): @@ -1670,9 +1690,10 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) - agent_recover = agent_subparsers.add_parser( + agent_recover = _add_subparser( + agent_subparsers, "recover", - help="Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", + "Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", ) agent_recover.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_recover.add_argument( @@ -1681,9 +1702,10 @@ def main(argv=None): help="Wait for a local wake after recovery.", ) - agent_set_heartbeat = agent_subparsers.add_parser( + agent_set_heartbeat = _add_subparser( + agent_subparsers, "set-heartbeat", - help="Update the heartbeat interval for one durable agent.", + "Update the heartbeat interval for one durable agent.", ) agent_set_heartbeat.add_argument( "agent_ref", @@ -1695,9 +1717,10 @@ def main(argv=None): help="Heartbeat interval in minutes.", ) - agent_delete = agent_subparsers.add_parser( + agent_delete = _add_subparser( + agent_subparsers, "delete", - help="Delete one durable agent and its files.", + "Delete one durable agent and its files.", ) agent_delete.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_delete.add_argument( @@ -1706,27 +1729,32 @@ def main(argv=None): help="Delete even when the agent is not terminal or still has children.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "tick", - help="Process due agents for the current host.", + "Process due agents for the current host.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "install-cron", - help="Install or update the cron entry for this CODEXAPI_HOME.", + "Install or update the cron entry for this CODEXAPI_HOME.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "uninstall-cron", - help="Remove the cron entry for this CODEXAPI_HOME.", + "Remove the cron entry for this CODEXAPI_HOME.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "tick", - help="Run one full background tick.", + "Run one full background tick.", ) - task_parser = subparsers.add_parser( + task_parser = _add_subparser( + subparsers, "task", - help="Run a task with verification retries.", + "Run a task with verification retries.", ) task_parser.add_argument( "-f", @@ -1806,9 +1834,10 @@ def main(argv=None): help="With -p, keep taking tasks and wait when none are available.", ) - ralph_parser = subparsers.add_parser( + ralph_parser = _add_subparser( + subparsers, "ralph", - help="Run a Ralph loop.", + "Run a Ralph loop.", epilog=ralph_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1864,9 +1893,10 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - science_parser = subparsers.add_parser( + science_parser = _add_subparser( + subparsers, "science", - help="Run a science-mode Ralph loop.", + "Run a science-mode Ralph loop.", epilog=science_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1929,9 +1959,10 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - foreach_parser = subparsers.add_parser( + foreach_parser = _add_subparser( + subparsers, "foreach", - help="Run a task file over a list file.", + "Run a task file over a list file.", ) foreach_parser.add_argument( "list_file", @@ -1974,18 +2005,20 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - create_parser = subparsers.add_parser( + create_parser = _add_subparser( + subparsers, "create", - help="Create a task file template.", + "Create a task file template.", ) create_parser.add_argument( "filename", help="Filename for the new task file.", ) - reset_parser = subparsers.add_parser( + reset_parser = _add_subparser( + subparsers, "reset", - help="Reset project tasks back to Ready.", + "Reset project tasks back to Ready.", ) reset_parser.add_argument( "-p", @@ -2006,13 +2039,15 @@ def main(argv=None): help="Remove any Progress section in the issue body.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "top", - help="Show running Codex sessions.", + "Show running Codex sessions.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "limit", - help="Show Codex rate limits.", + "Show Codex rate limits.", ) args = parser.parse_args(argv) diff --git a/tests/test_agents.py b/tests/test_agents.py index 51a8fb9..5a9c4c8 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -88,6 +88,30 @@ def test_cli_version(self): self.assertEqual(exc.exception.code, 0) self.assertEqual(output.getvalue().strip(), f"codexapi {__version__}") + def test_cli_subcommand_help_shows_one_line_description(self): + cases = ( + ( + ["task", "--help"], + "Run a task with verification retries.", + ), + ( + ["agent", "resume", "--help"], + "Resume a paused agent and return immediately unless --wait is set.", + ), + ( + ["agent", "list", "--help"], + "List durable agents in this CODEXAPI_HOME.", + ), + ) + for argv, expected in cases: + with self.subTest(argv=argv): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(argv) + self.assertEqual(exc.exception.code, 0) + self.assertIn(expected, output.getvalue()) + def test_current_hostname_prefers_override(self): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): from codexapi.agents import current_hostname From 4fe414bd73363406595df935328bc62520c91da1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 24 Apr 2026 00:13:16 +0200 Subject: [PATCH 76/78] Release v0.12.9 --- README.md | 23 +++++++++---- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 37 ++++++++++++++++++--- src/codexapi/agents.py | 32 +++++++++++++----- src/codexapi/async_agent.py | 32 ++++++++++++++---- src/codexapi/cli.py | 46 +++++++++++++++++++++++++ src/codexapi/foreach.py | 4 +++ src/codexapi/gh_integration.py | 5 ++- src/codexapi/lead.py | 7 +++- src/codexapi/ralph.py | 44 ++++++++++++++---------- src/codexapi/science.py | 25 ++++++++++++-- src/codexapi/task.py | 61 ++++++++++++++++++++++++++-------- src/codexapi/taskfile.py | 3 ++ tests/test_agent_backend.py | 27 ++++++++++++++- tests/test_agents.py | 27 +++++++++++++++ tests/test_async_agent.py | 61 ++++++++++++++++++++++++++++++++++ 17 files changed, 374 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c75ebf1..a351dfb 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ for update in agent.watch(poll_interval=2.0): Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. +Use `fast=True` in Codex API calls, or `--fast` in the CLI, to opt into Codex +fast mode. Normal mode is the default. ## CLI @@ -73,6 +75,7 @@ codexapi --version codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run +codexapi run --fast "Summarize this repo quickly." codexapi run --backend cursor "Summarize this repo." ``` @@ -318,7 +321,7 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False) -> str` Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. @@ -329,8 +332,9 @@ items are filtered out. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -343,9 +347,10 @@ the same conversation and returns only the agent's message. and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. -### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None) -> dict` +### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None, fast=False) -> dict` Runs a long-lived agent session and periodically checks in with the current local time and a reminder of `prompt`. Each check-in expects JSON with keys: @@ -358,7 +363,7 @@ Lead also injects the leadbook content into each prompt. By default it uses path string to override the location. Set `backend="cursor"` (or `CODEXAPI_BACKEND=cursor`) to use Cursor. -### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> str` +### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None, fast=False) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum iterations are reached. @@ -368,14 +373,15 @@ Raises `TaskFailed` when the maximum iterations are reached. - `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> TaskResult` +### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None, fast=False) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. Arguments mirror `task()` (including hooks). -### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None, fast=False)` Runs an agent task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return @@ -408,7 +414,7 @@ Exception raised by `task()` when iterations are exhausted. - `iterations` (int | None): iterations made when the task failed. - `errors` (str | None): last checker error, if any. -### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None) -> ForeachResult` +### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None, fast=False) -> ForeachResult` Runs a task file over a list of items, updating the list file in place. @@ -419,6 +425,7 @@ Runs a task file over a list of items, updating the list file in place. - `yolo` (bool): pass `--yolo` when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to the agent backend. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). ### `ForeachResult(succeeded, failed, skipped, results)` @@ -433,6 +440,8 @@ Simple result object returned by `foreach()`. - Codex backend uses `codex exec --json` and parses JSONL `agent_message` items. - Codex backend passes `--skip-git-repo-check` so it can run outside a git repo. +- Codex backend defaults to normal mode and passes `features.fast_mode=false`; + `fast=True` / `--fast` also passes `service_tier=fast` and `features.fast_mode=true`. - Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. - `include_thinking=True` only affects Codex; Cursor returns a single result string. - Passes `--yolo` by default (Codex uses `--full-auto` when disabled). diff --git a/pyproject.toml b/pyproject.toml index d095bd5..c99abdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.8" +version = "0.12.9" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index a29d7ce..0a6de5e 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.8" +__version__ = "0.12.9" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 4fb3e7d..3323677 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -54,6 +54,7 @@ def agent( include_thinking=False, backend=None, env=None, + fast=False, ): """Run a single agent turn and return only the agent's message. @@ -65,12 +66,13 @@ def agent( include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id, _usage = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend, env + prompt, cwd, None, yolo, flags, include_thinking, backend, env, fast ) return message @@ -103,6 +105,7 @@ def __init__( include_thinking=False, backend=None, env=None, + fast=False, ): """Create a new session wrapper. @@ -116,6 +119,7 @@ def __init__( include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. """ self.cwd = cwd self._yolo = yolo @@ -125,6 +129,7 @@ def __init__( self.thread_id = thread_id self._backend = backend self._env = env + self._fast = fast self.last_usage = {} def __call__(self, prompt): @@ -140,6 +145,7 @@ def __call__(self, prompt): self._include_thinking, self._backend, self._env, + self._fast, ) if thread_id: self.thread_id = thread_id @@ -149,15 +155,25 @@ def __call__(self, prompt): return message -def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): +def _run_agent( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + backend, + env, + fast=False, +): backend = _resolve_backend(backend) _ensure_backend_available(backend, env) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast) return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): +def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast=False): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -171,6 +187,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): command.append("--yolo") else: command.append("--full-auto") + command.extend(_codex_fast_config(fast)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -198,6 +215,18 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): return _parse_jsonl(result.stdout, include_thinking) +def _codex_fast_config(fast): + """Return Codex config flags for normal or fast mode.""" + if fast: + return [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ] + return ["-c", "features.fast_mode=false"] + + def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" command = [ diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 00e16a9..bd91ff7 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -210,6 +210,7 @@ def start_agent( home=None, hostname=None, now=None, + fast=False, ): """Create a durable agent and return its current snapshot.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -250,6 +251,7 @@ def start_agent( "backend": backend_name, "yolo": bool(yolo), "flags": flags or "", + "fast": bool(fast), "cwd": cwd, "env": session_env, "pending_messages": [], @@ -1053,15 +1055,27 @@ def _run_agent_turn(meta, session, prompt, runner=None): raise TypeError("runner must return a dict") return outcome started = utc_now() - worker = Agent( - session.get("cwd") or meta.get("cwd"), - session.get("yolo", True), - session.get("thread_id") or None, - session.get("flags") or None, - include_thinking=False, - backend=session.get("backend") or None, - env=_agent_env(meta, session), - ) + if session.get("fast", False): + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + fast=True, + ) + else: + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + ) message = worker(prompt) usage = worker.last_usage or {} rollout_path = "" diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index a1ebce9..e203057 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _CURSOR_BIN, + _codex_fast_config, _ensure_backend_available, _event_usage, _merged_env, @@ -48,6 +49,7 @@ def __init__( self._lock = threading.Lock() self._stdout_lines: list[str] = [] self._stderr_lines: list[str] = [] + self._errors: list[str] = [] self._messages: list[str] = [] self._thread_id = "" self._rollout_path = "" @@ -85,6 +87,7 @@ def start( backend=None, env=None, name=None, + fast=False, ): """Start a backend subprocess and return an async handle immediately.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -92,7 +95,7 @@ def start( backend = _resolve_backend(backend) _ensure_backend_available(backend, env) - command = _build_command(backend, cwd, yolo, flags) + command = _build_command(backend, cwd, yolo, flags, fast) process = subprocess.Popen( command, stdin=subprocess.PIPE, @@ -157,6 +160,7 @@ def status(self, include_actions=False) -> dict[str, object]: progress = list(self._progress) tools = list(self._tools) if include_actions else [] stderr_lines = list(self._stderr_lines) + error_lines = list(self._errors) thread_id = self._thread_id rollout_path = self._rollout_path last_event_at = self._last_event_at @@ -168,7 +172,9 @@ def status(self, include_actions=False) -> dict[str, object]: progress=progress, final_output=final_output, stderr_lines=stderr_lines, + error_lines=error_lines, ) + last_error = error_lines[-1] if error_lines else stderr_lines[-1] if stderr_lines else "" return { "id": self.id, "name": self.name, @@ -184,8 +190,9 @@ def status(self, include_actions=False) -> dict[str, object]: "final_output": final_output, "last_event_at": last_event_at, "returncode": returncode, - "last_error": stderr_lines[-1] if stderr_lines else "", + "last_error": last_error, "stderr": "\n".join(stderr_lines), + "errors": error_lines, "messages": messages, "usage": last_usage, } @@ -310,6 +317,16 @@ def _handle_stdout_line(self, line: str) -> None: text = item.get("text") if isinstance(text, str): self._messages.append(text) + elif event.get("type") == "error": + message = event.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) + elif event.get("type") == "turn.failed": + error = event.get("error") or {} + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) def _refresh_rollout(self) -> None: if self.backend != "codex": @@ -359,13 +376,13 @@ def _current_final_output_locked(self) -> str: return self._rollout_final_output -def _build_command(backend, cwd, yolo, flags): +def _build_command(backend, cwd, yolo, flags, fast=False): if backend == "codex": - return _build_codex_command(cwd, yolo, flags) + return _build_codex_command(cwd, yolo, flags, fast) return _build_cursor_command(cwd, yolo, flags) -def _build_codex_command(cwd, yolo, flags): +def _build_codex_command(cwd, yolo, flags, fast=False): command = [ _CODEX_BIN, "exec", @@ -378,6 +395,7 @@ def _build_codex_command(cwd, yolo, flags): command.append("--yolo") else: command.append("--full-auto") + command.extend(_codex_fast_config(fast)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -425,9 +443,11 @@ def _status_text(returncode, canceled): return "error" -def _activity_text(status, progress, final_output, stderr_lines): +def _activity_text(status, progress, final_output, stderr_lines, error_lines=None): if progress: return progress[-1] + if status == "error" and error_lines: + return error_lines[-1] if status == "error" and stderr_lines: return stderr_lines[-1] if final_output: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 11e2a9d..830f0ab 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1470,6 +1470,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + run_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) run_parser.add_argument( "--no-yolo", action="store_false", @@ -1512,6 +1517,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + lead_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) lead_parser.add_argument( "--leadbook", help="Path to the leadbook file (default: LEADBOOK.md in cwd).", @@ -1585,6 +1595,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + agent_start.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) agent_start.add_argument( "--no-yolo", action="store_false", @@ -1813,6 +1828,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + task_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) task_parser.add_argument( "--no-yolo", action="store_false", @@ -1882,6 +1902,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + ralph_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) ralph_parser.add_argument( "--no-yolo", action="store_false", @@ -1948,6 +1973,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + science_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) science_parser.add_argument( "--no-yolo", action="store_false", @@ -1994,6 +2024,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + foreach_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) foreach_parser.add_argument( "--no-yolo", action="store_false", @@ -2072,6 +2107,7 @@ def main(argv=None): args.backend, args.yolo, args.flags, + fast=args.fast, ) except RuntimeError as exc: raise SystemExit(str(exc)) from None @@ -2215,6 +2251,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) if result.failed: raise SystemExit(1) @@ -2285,6 +2322,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: print(str(exc), file=sys.stderr) @@ -2314,6 +2352,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: raise SystemExit(str(exc)) from None @@ -2350,6 +2389,7 @@ def main(argv=None): thread_id=None, flags=args.flags, backend=args.backend, + fast=args.fast, ) result = task_runner(progress=not args.quiet) if not result.success: @@ -2384,6 +2424,7 @@ def main(argv=None): args.completion_promise, args.ralph_fresh, args.backend, + args.fast, )() return if args.command == "science": @@ -2400,6 +2441,7 @@ def main(argv=None): args.ralph_fresh, max_duration_seconds, args.backend, + args.fast, )() return if args.command == "lead": @@ -2417,6 +2459,7 @@ def main(argv=None): args.flags, leadbook, args.backend, + args.fast, ) except KeyboardInterrupt: raise SystemExit(130) @@ -2453,6 +2496,7 @@ def main(argv=None): args.flags, not args.quiet, backend=args.backend, + fast=args.fast, ) except TaskFailed as exc: exit_code = 1 @@ -2466,6 +2510,7 @@ def main(argv=None): args.flags, include_thinking=args.include_thinking, backend=args.backend, + fast=args.fast, ) message = session(prompt) if args.print_thread_id: @@ -2478,6 +2523,7 @@ def main(argv=None): args.flags, args.include_thinking, args.backend, + fast=args.fast, ) if message is not None: diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index c7cc6a0..b78676a 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -42,6 +42,7 @@ def foreach( yolo=True, flags=None, backend=None, + fast=False, ): """Run a task file over each item in list_file and update the file.""" lines, ends_with_newline = _read_lines(list_file) @@ -77,6 +78,7 @@ def foreach( yolo, flags, backend, + fast, counts, results, progress, @@ -174,6 +176,7 @@ def _run_item( yolo, flags, backend, + fast, counts, results, progress, @@ -199,6 +202,7 @@ def _run_item( thread_id=None, flags=flags, backend=backend, + fast=fast, ) max_iterations = task.max_iterations result = task() diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index f7563cd..8731f76 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -235,8 +235,9 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): - super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend) + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend, fast) self.issue = issue self.project = project self._progress_updates = True @@ -310,6 +311,7 @@ def __init__( yolo=True, flags=None, backend=None, + fast=False, ): task_map = _task_file_map(task_files) self.project = Project(project, name, has_label=list(task_map)) @@ -340,6 +342,7 @@ def __init__( None, flags, backend, + fast, ) def __call__(self, progress=False): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 1918dc9..a7c28b5 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -83,6 +83,7 @@ def lead( flags=None, leadbook=None, backend=None, + fast=False, ): """Run a periodic lead loop. @@ -94,6 +95,7 @@ def lead( flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The last parsed JSON status object. @@ -106,7 +108,10 @@ def lead( raise ValueError("prompt must be a non-empty string") interval = minutes * 60 - session = Agent(cwd, yolo, None, flags, backend=backend) + if fast: + session = Agent(cwd, yolo, None, flags, backend=backend, fast=True) + else: + session = Agent(cwd, yolo, None, flags, backend=backend) pushover = Pushover() pushover.ensure_ready() title = _format_title(prompt) diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 205e5af..c9aad33 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -25,6 +25,7 @@ def __init__( completion_promise=None, fresh=True, backend=None, + fast=False, ): if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -43,6 +44,7 @@ def __init__( self.completion_promise = completion_promise self.fresh = fresh self.backend = backend + self.fast = fast self.include_thinking = True def hook_before_loop(self): @@ -159,25 +161,9 @@ def __call__(self): self.hook_before_iteration(iteration) if self.fresh: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() elif runner is None: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() prompt = self.build_prompt(iteration) stopped = False @@ -250,6 +236,28 @@ def __call__(self): _cleanup_state(state_path) self.hook_after_loop(last_message, stop_reason) + def _new_agent(self): + if self.fast: + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + fast=True, + ) + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + ) + def cancel_ralph_loop(cwd=None): """Cancel the Ralph loop by removing the state file.""" diff --git a/src/codexapi/science.py b/src/codexapi/science.py index b976be8..21d5583 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -103,6 +103,7 @@ def __init__( fresh=True, max_duration_seconds=0, backend=None, + fast=False, ): if max_duration_seconds < 0: raise ValueError("max_duration_seconds must be >= 0") @@ -118,6 +119,7 @@ def __init__( completion_promise, fresh, backend, + fast, ) self.include_thinking = True self._prompt_a = prompt_a @@ -132,6 +134,7 @@ def __init__( self._duration_limit_hit = False self._last_iteration = 0 self._backend = backend + self._fast = fast def hook_before_loop(self): super().hook_before_loop() @@ -199,7 +202,7 @@ def _append_logbook(self, iteration, message): def _extract_and_notify(self, message): prompt = _build_metrics_prompt(self._task, message, self._best_metrics) try: - output = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + output = self._agent(prompt) except Exception as exc: _warn(f"Metrics extraction failed: {exc}") return @@ -222,7 +225,7 @@ def _build_run_title(self): ] ) try: - title = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + title = self._agent(prompt) except Exception: title = "" title = _single_line(title).strip() @@ -230,6 +233,24 @@ def _build_run_title(self): title = _fallback_title(self._task) return title + def _agent(self, prompt): + if self._fast: + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + fast=True, + ) + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + ) + def _mark_duration_stop(self, iteration): if self._duration_limit_hit: return diff --git a/src/codexapi/task.py b/src/codexapi/task.py index d54d731..436425a 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -192,6 +192,7 @@ def estimate( flags, previous_total, backend=None, + fast=False, ): estimate_prompt = _build_estimate_prompt( prompt, @@ -199,10 +200,16 @@ def estimate( check_output or "", previous_total, ) - output = agent(estimate_prompt, cwd, yolo, flags, backend=backend) + output = _call_agent(estimate_prompt, cwd, yolo, flags, backend, fast) return _estimate_result(output) +def _call_agent(prompt, cwd, yolo, flags, backend, fast): + if fast: + return agent(prompt, cwd, yolo, flags, backend=backend, fast=True) + return agent(prompt, cwd, yolo, flags, backend=backend) + + def _fix_prompt(error): return ( "Thanks for your work. An automated verifier reported these issues:\n" @@ -258,6 +265,7 @@ def task( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries. @@ -275,6 +283,7 @@ def task( on_success: Optional prompt to run after a successful task. on_failure: Optional prompt to run after a failed task. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The agent's response text when the task succeeds. @@ -295,6 +304,7 @@ def task( on_success, on_failure, backend, + fast, ) if result.success: return result.summary @@ -314,6 +324,7 @@ def task_result( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries and return TaskResult. @@ -344,6 +355,7 @@ def task_result( on_success=on_success_text, on_failure=on_failure_text, backend=backend, + fast=fast, ) return runner(progress=progress) @@ -390,6 +402,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): if max_iterations < 0: raise ValueError("max_iterations must be >= 0") @@ -403,20 +416,32 @@ def __init__( self._yolo = yolo self._flags = flags self._backend = backend + self._fast = fast self._progress_enabled = False self._progress_updates = False self._progress_bar = None self._progress_total = None self._progress_start = None self._pushover = Pushover() - self.agent = Agent( - cwd, - yolo, - thread_id, - flags, - welfare=True, - backend=backend, - ) + if fast: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + fast=True, + ) + else: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + ) def set_up(self): """Clone a repo, set up a directory etc.""" @@ -439,12 +464,13 @@ def check(self, output=None): last_output = output if output is not None else self.last_output last_output = last_output or "" check_prompt = _build_check_prompt(check_text, last_output) - check_output = agent( + check_output = _call_agent( check_prompt, self.cwd, self._yolo, self._flags, - backend=self._backend, + self._backend, + self._fast, ) self.last_check_output = check_output success, reason = _check_result(check_output) @@ -522,6 +548,7 @@ def _estimate_progress(self, agent_output, check_output): self._flags, self._progress_total, backend=self._backend, + fast=self._fast, ), None, ) @@ -696,6 +723,7 @@ def __init__( on_success=None, on_failure=None, backend=None, + fast=False, ): if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") @@ -709,6 +737,7 @@ def __init__( thread_id, flags, backend, + fast, ) self.check_text = check self._set_up = _validate_hook("set_up", set_up) @@ -718,7 +747,14 @@ def __init__( def _run_hook(self, text): if text: - agent(text, self.cwd, self._yolo, self._flags, backend=self._backend) + _call_agent( + text, + self.cwd, + self._yolo, + self._flags, + self._backend, + self._fast, + ) def set_up(self): self._run_hook(self._set_up) @@ -731,4 +767,3 @@ def on_success(self, result): def on_failure(self, result): self._run_hook(self._on_failure) - diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index e9e606b..7aa9071 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -78,6 +78,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): task_def = load_task_file(path) if max_iterations is None: @@ -108,6 +109,7 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) return super().__init__( @@ -123,4 +125,5 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index ae443b3..d54a5fb 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -5,10 +5,35 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from codexapi.agent import _parse_jsonl +from codexapi.agent import _codex_fast_config, _parse_jsonl +from codexapi.async_agent import _build_codex_command class AgentBackendTests(unittest.TestCase): + def test_codex_fast_config_defaults_to_normal_mode(self): + self.assertEqual(_codex_fast_config(False), ["-c", "features.fast_mode=false"]) + + def test_codex_fast_config_enables_fast_mode(self): + self.assertEqual( + _codex_fast_config(True), + [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ], + ) + + def test_async_codex_command_uses_normal_mode_by_default(self): + command = _build_codex_command(None, True, None) + self.assertIn("features.fast_mode=false", command) + self.assertNotIn("service_tier=fast", command) + + def test_async_codex_command_can_enable_fast_mode(self): + command = _build_codex_command(None, True, None, fast=True) + self.assertIn("service_tier=fast", command) + self.assertIn("features.fast_mode=true", command) + def test_parse_jsonl_extracts_last_token_usage(self): output = "\n".join( [ diff --git a/tests/test_agents.py b/tests/test_agents.py index 5a9c4c8..06cb2aa 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -142,6 +142,18 @@ def test_homes_are_isolated(self): second = start_agent("Watch CI failures.", hostname="host-b") self.assertNotEqual(first["id"], second["id"]) + def test_start_agent_defaults_to_normal_mode(self): + with _temp_home(): + started = start_agent("Use normal mode.", hostname="host-a") + shown = show_agent(started["id"]) + self.assertFalse(shown["session"]["fast"]) + + def test_start_agent_can_enable_fast_mode(self): + with _temp_home(): + started = start_agent("Use fast mode.", hostname="host-a", fast=True) + shown = show_agent(started["id"]) + self.assertTrue(shown["session"]["fast"]) + def test_read_agentbook_and_cli_book(self): with _temp_home(): agent = start_agent("Keep notes.", hostname="host-a") @@ -1298,6 +1310,21 @@ def test_cli_start_warns_when_cron_missing(self): self.assertIn(str(home), warning) self.assertIn("codexapi agent install-cron", warning) + def test_cli_start_can_enable_fast_mode(self): + with _temp_home(): + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "--fast", "Handle messages."]) + payload = json.loads(output.getvalue()) + shown = show_agent(payload["id"]) + self.assertTrue(shown["session"]["fast"]) + def test_cli_start_warns_when_scheduler_is_broken(self): output = io.StringIO() errors = io.StringIO() diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py index f1cbc57..f31cc2e 100644 --- a/tests/test_async_agent.py +++ b/tests/test_async_agent.py @@ -153,6 +153,67 @@ def test_async_agent_reports_rollout_progress_and_final_output(self): {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14}, ) + def test_async_agent_reports_codex_json_error_events(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import sys + + sys.stdin.read() + print(json.dumps({"type": "thread.started", "thread_id": "thread-error"}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + print( + json.dumps( + { + "type": "error", + "message": "Reconnecting... 5/5 (model unavailable)", + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.failed", + "error": {"message": "The model `gpt-5.5` does not exist."}, + } + ), + flush=True, + ) + raise SystemExit(1) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-error-test", + ) + final = agent.wait(poll_interval=0.01) + + self.assertEqual(final["status"], "error") + self.assertEqual(final["last_error"], "The model `gpt-5.5` does not exist.") + self.assertIn("model unavailable", final["errors"][0]) + self.assertEqual(final["activity"], "The model `gpt-5.5` does not exist.") + if __name__ == "__main__": unittest.main() From c976e3c3a1e438aad3910abf892ddb7942b4bb1e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 27 Apr 2026 13:01:37 +0200 Subject: [PATCH 77/78] Add model and thinking backend overrides --- README.md | 10 ++- pyproject.toml | 2 +- src/codexapi/__init__.py | 5 +- src/codexapi/agent.py | 153 +++++++++++++++++++++++++++++++++--- src/codexapi/async_agent.py | 23 +++--- tests/test_agent_backend.py | 34 +++++++- 6 files changed, 200 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index a351dfb..3408e33 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. Use `fast=True` in Codex API calls, or `--fast` in the CLI, to opt into Codex fast mode. Normal mode is the default. +Use `model="..."` and `thinking="..."` in Codex API calls to override the +backend model and reasoning effort for a run. ## CLI @@ -321,7 +323,7 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False, model=None, thinking=None) -> str` Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. @@ -333,8 +335,10 @@ items are filtered out. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. Codex maps this to `-c model=...`; Cursor maps it to `--model ...`. +- `thinking` (str | None): Codex reasoning effort override, mapped to `-c model_reasoning_effort=...`. -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False, model=None, thinking=None)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -348,6 +352,8 @@ the same conversation and returns only the agent's message. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. +- `thinking` (str | None): Codex reasoning effort override. For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. ### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None, fast=False) -> dict` diff --git a/pyproject.toml b/pyproject.toml index c99abdf..c290aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.9" +version = "0.12.10" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0a6de5e..5fc8665 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,6 @@ """Minimal Python API for running agent CLIs.""" -from .agent import Agent, WelfareStop, agent +from .agent import Agent, WelfareStop, agent, build_agent_flags from .async_agent import AsyncAgent from .foreach import ForeachResult, foreach from .pushover import Pushover @@ -15,6 +15,7 @@ "AsyncAgent", "ForeachResult", "Pushover", + "build_agent_flags", "quota_line", "rate_limits", "Ralph", @@ -29,4 +30,4 @@ "task_result", "lead", ] -__version__ = "0.12.9" +__version__ = "0.12.10" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 3323677..d2e9fb8 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -11,6 +11,7 @@ _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") _CURSOR_BIN = os.environ.get("CURSOR_BIN", "cursor") _SUPPORTED_BACKENDS = {"codex", "cursor"} +_CURSOR_AGENT_BIN = os.path.expanduser("~/.local/bin/cursor-agent") def _resolve_backend(backend): @@ -33,12 +34,15 @@ def _ensure_backend_available(backend, env=None): env_var = "CODEX_BIN" label = "Codex CLI" else: - command = _CURSOR_BIN + command = _cursor_bin(env) env_var = "CURSOR_BIN" label = "Cursor agent CLI" merged = _merged_env(env) path_value = None if merged is None else merged.get("PATH") - resolved = shutil.which(command, path=path_value) + if os.path.isabs(command): + resolved = command if os.path.exists(command) else None + else: + resolved = shutil.which(command, path=path_value) if resolved: return resolved raise RuntimeError( @@ -55,6 +59,8 @@ def agent( backend=None, env=None, fast=False, + model=None, + thinking=None, ): """Run a single agent turn and return only the agent's message. @@ -67,12 +73,24 @@ def agent( backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id, _usage = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend, env, fast + prompt, + cwd, + None, + yolo, + flags, + include_thinking, + backend, + env, + fast, + model, + thinking, ) return message @@ -106,6 +124,8 @@ def __init__( backend=None, env=None, fast=False, + model=None, + thinking=None, ): """Create a new session wrapper. @@ -120,6 +140,8 @@ def __init__( backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. """ self.cwd = cwd self._yolo = yolo @@ -130,6 +152,8 @@ def __init__( self._backend = backend self._env = env self._fast = fast + self._model = model + self._thinking = thinking self.last_usage = {} def __call__(self, prompt): @@ -146,6 +170,8 @@ def __call__(self, prompt): self._backend, self._env, self._fast, + self._model, + self._thinking, ) if thread_id: self.thread_id = thread_id @@ -165,15 +191,49 @@ def _run_agent( backend, env, fast=False, + model=None, + thinking=None, ): backend = _resolve_backend(backend) _ensure_backend_available(backend, env) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast) - return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast, + model, + thinking, + ) + return _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model, + thinking, + ) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast=False): +def _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast=False, + model=None, + thinking=None, +): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -188,6 +248,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast= else: command.append("--full-auto") command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -227,11 +288,82 @@ def _codex_fast_config(fast): return ["-c", "features.fast_mode=false"] -def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): +def _cursor_bin(env=None): + merged = _merged_env(env) + env_source = merged or os.environ + override = env_source.get("CURSOR_BIN", "").strip() + if override: + return os.path.expanduser(override) + + path_value = None if merged is None else merged.get("PATH") + direct = shutil.which("cursor-agent", path=path_value) + if direct: + return direct + if os.path.exists(_CURSOR_AGENT_BIN): + return _CURSOR_AGENT_BIN + return _CURSOR_BIN + + +def _cursor_command_prefix(env=None): + command = _cursor_bin(env) + if os.path.basename(command) == "cursor-agent": + return [command] + return [command, "agent"] + + +def build_agent_flags(*, backend=None, model=None, thinking=None, flags=None): + """Return raw backend flags for a model/thinking configuration. + + The returned string is suitable for APIs that accept the existing ``flags`` + parameter. + """ + backend = _resolve_backend(backend) + parts = _agent_config_flag_parts(backend, model, thinking) + if flags: + parts.extend(shlex.split(flags)) + return shlex.join(parts) + + +def _agent_config_flag_parts(backend, model=None, thinking=None): + backend = _resolve_backend(backend) + parts = [] + model = _clean_optional_text(model) + thinking = _clean_optional_text(thinking) + + if backend == "codex": + if model: + parts.extend(["-c", f"model={model}"]) + if thinking: + parts.extend(["-c", f"model_reasoning_effort={thinking}"]) + return parts + + if model: + parts.extend(["--model", model]) + if thinking: + raise ValueError("thinking is only supported by the codex backend") + return parts + + +def _clean_optional_text(value): + if value is None: + return None + text = str(value).strip() + return text or None + + +def _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model=None, + thinking=None, +): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" - command = [ - _CURSOR_BIN, - "agent", + command = _cursor_command_prefix(env) + [ "--trust", ] if cwd: @@ -240,6 +372,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): command.extend(["--resume", thread_id]) if yolo: command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) if flags: command.extend(shlex.split(flags)) command.extend(["--print", "--output-format", "json"]) diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index e203057..ac0eca1 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -12,8 +12,9 @@ from .agent import ( _CODEX_BIN, - _CURSOR_BIN, + _agent_config_flag_parts, _codex_fast_config, + _cursor_command_prefix, _ensure_backend_available, _event_usage, _merged_env, @@ -88,6 +89,8 @@ def start( env=None, name=None, fast=False, + model=None, + thinking=None, ): """Start a backend subprocess and return an async handle immediately.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -95,7 +98,7 @@ def start( backend = _resolve_backend(backend) _ensure_backend_available(backend, env) - command = _build_command(backend, cwd, yolo, flags, fast) + command = _build_command(backend, cwd, yolo, flags, fast, model, thinking) process = subprocess.Popen( command, stdin=subprocess.PIPE, @@ -376,13 +379,13 @@ def _current_final_output_locked(self) -> str: return self._rollout_final_output -def _build_command(backend, cwd, yolo, flags, fast=False): +def _build_command(backend, cwd, yolo, flags, fast=False, model=None, thinking=None): if backend == "codex": - return _build_codex_command(cwd, yolo, flags, fast) - return _build_cursor_command(cwd, yolo, flags) + return _build_codex_command(cwd, yolo, flags, fast, model, thinking) + return _build_cursor_command(cwd, yolo, flags, model, thinking) -def _build_codex_command(cwd, yolo, flags, fast=False): +def _build_codex_command(cwd, yolo, flags, fast=False, model=None, thinking=None): command = [ _CODEX_BIN, "exec", @@ -396,6 +399,7 @@ def _build_codex_command(cwd, yolo, flags, fast=False): else: command.append("--full-auto") command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -404,16 +408,15 @@ def _build_codex_command(cwd, yolo, flags, fast=False): return command -def _build_cursor_command(cwd, yolo, flags): - command = [ - _CURSOR_BIN, - "agent", +def _build_cursor_command(cwd, yolo, flags, model=None, thinking=None): + command = _cursor_command_prefix() + [ "--trust", ] if cwd: command.extend(["--workspace", os.fspath(cwd)]) if yolo: command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) if flags: command.extend(shlex.split(flags)) command.extend(["--print", "--output-format", "json"]) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index d54a5fb..95d8031 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -2,11 +2,12 @@ import sys import unittest from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from codexapi.agent import _codex_fast_config, _parse_jsonl -from codexapi.async_agent import _build_codex_command +from codexapi.agent import _codex_fast_config, _parse_jsonl, build_agent_flags +from codexapi.async_agent import _build_codex_command, _build_cursor_command class AgentBackendTests(unittest.TestCase): @@ -34,6 +35,35 @@ def test_async_codex_command_can_enable_fast_mode(self): self.assertIn("service_tier=fast", command) self.assertIn("features.fast_mode=true", command) + def test_build_agent_flags_maps_codex_model_and_thinking_to_config(self): + self.assertEqual( + build_agent_flags(backend="codex", model="gpt-5.5", thinking="xhigh"), + "-c model=gpt-5.5 -c model_reasoning_effort=xhigh", + ) + + def test_build_agent_flags_maps_cursor_model_to_model_flag(self): + self.assertEqual( + build_agent_flags(backend="cursor", model="claude-4"), + "--model claude-4", + ) + + def test_build_agent_flags_rejects_cursor_thinking(self): + with self.assertRaises(ValueError): + build_agent_flags(backend="cursor", thinking="high") + + def test_async_codex_command_can_set_model_and_thinking(self): + command = _build_codex_command(None, True, None, model="gpt-5.5", thinking="high") + self.assertIn("model=gpt-5.5", command) + self.assertIn("model_reasoning_effort=high", command) + + def test_async_cursor_command_can_use_direct_cursor_agent(self): + with patch("codexapi.async_agent._cursor_command_prefix", return_value=["/tmp/cursor-agent"]): + command = _build_cursor_command("/tmp/work", True, None, model="composer-2") + self.assertEqual(command[0], "/tmp/cursor-agent") + self.assertNotEqual(command[1], "agent") + self.assertIn("--model", command) + self.assertIn("composer-2", command) + def test_parse_jsonl_extracts_last_token_usage(self): output = "\n".join( [ From 922dcd25adb1816910af619eabd805902f88ac43 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 23 Jun 2026 13:03:51 +0200 Subject: [PATCH 78/78] Release v0.12.11 --- README.md | 21 +++++++++++++++------ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 18 ++++++++++-------- src/codexapi/async_agent.py | 8 ++------ src/codexapi/cli.py | 17 +++++++++-------- src/codexapi/lead.py | 2 +- src/codexapi/task.py | 2 +- tests/test_agent_backend.py | 19 +++++++++++++++++++ 9 files changed, 59 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3408e33..b23726e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ Resume a session and print the thread/session id to stderr: codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` -Use `--no-yolo` to disable `--yolo` (Codex uses `--full-auto`). +Use `--no-yolo` to keep unattended operation with Codex auto approvals, without +forcing a sandbox policy. Use `--include-thinking` to return all agent messages joined together for `codexapi run` (Codex only). Lead mode periodically checks in on a long-running agent session with the @@ -290,7 +291,8 @@ codexapi ralph --cancel --cwd /path/to/project ``` Science mode wraps a short task in a science prompt and runs it through the -Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. +Ralph loop. It defaults to dangerous no-sandbox automation and expects progress +notes in `SCIENCE.md`. Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts any improved figures of merit for optional notifications. You can also set `--max-duration` to stop after the current iteration once a time limit is hit. @@ -330,7 +332,9 @@ items are filtered out. - `prompt` (str): prompt to send to the agent backend. - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). @@ -345,7 +349,9 @@ the same conversation and returns only the agent's message. - `__call__(prompt) -> str`: send a prompt to the agent backend and return the message. - `thread_id -> str | None`: expose the underlying session id once created. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. @@ -428,7 +434,9 @@ Runs a task file over a list of items, updating the list file in place. - `task_file` (str | PathLike): YAML task file (must include `prompt`). - `n` (int | None): limit parallelism to N (default: run all items in parallel). - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). @@ -450,7 +458,8 @@ Simple result object returned by `foreach()`. `fast=True` / `--fast` also passes `service_tier=fast` and `features.fast_mode=true`. - Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. - `include_thinking=True` only affects Codex; Cursor returns a single result string. -- Passes `--yolo` by default (Codex uses `--full-auto` when disabled). +- Uses dangerous no-sandbox automation by default. For Codex, `yolo=False` + uses auto approvals without forcing a sandbox policy. - Raises `RuntimeError` if the backend exits non-zero or returns no agent message. ## Configuration diff --git a/pyproject.toml b/pyproject.toml index c290aa6..f93a31b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.10" +version = "0.12.11" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 5fc8665..bd80db3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -30,4 +30,4 @@ "task_result", "lead", ] -__version__ = "0.12.10" +__version__ = "0.12.11" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index d2e9fb8..8adad03 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -67,7 +67,7 @@ def agent( Args: prompt: The user prompt to send to the agent backend. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). @@ -131,7 +131,7 @@ def __init__( Args: cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. thread_id: Optional thread/session id to resume from the first call. flags: Additional raw CLI flags to pass to the agent backend. welfare: When true, append welfare stop instructions to each prompt @@ -235,18 +235,13 @@ def _run_codex( thinking=None, ): """Invoke the Codex CLI and return the message plus thread id (if any).""" - command = [ - _CODEX_BIN, + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ "exec", "--json", "--color", "never", "--skip-git-repo-check", ] - if yolo: - command.append("--yolo") - else: - command.append("--full-auto") command.extend(_codex_fast_config(fast)) command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: @@ -276,6 +271,13 @@ def _run_codex( return _parse_jsonl(result.stdout, include_thinking) +def _codex_automation_flags(yolo): + """Return current Codex CLI flags for unattended operation.""" + if yolo: + return ["--dangerously-bypass-approvals-and-sandbox"] + return ["--ask-for-approval", "never"] + + def _codex_fast_config(fast): """Return Codex config flags for normal or fast mode.""" if fast: diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index ac0eca1..edc2cf9 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _agent_config_flag_parts, + _codex_automation_flags, _codex_fast_config, _cursor_command_prefix, _ensure_backend_available, @@ -386,18 +387,13 @@ def _build_command(backend, cwd, yolo, flags, fast=False, model=None, thinking=N def _build_codex_command(cwd, yolo, flags, fast=False, model=None, thinking=None): - command = [ - _CODEX_BIN, + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ "exec", "--json", "--color", "never", "--skip-git-repo-check", ] - if yolo: - command.append("--yolo") - else: - command.append("--full-auto") command.extend(_codex_fast_config(fast)) command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 830f0ab..3e3e790 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1439,7 +1439,8 @@ def main(argv=None): science_help = ( "Science mode (science command):\n" " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" - " Default uses --yolo. Use --no-yolo to disable it.\n" + " Default uses dangerous no-sandbox automation. " + "Use --no-yolo for auto approvals without forcing sandbox policy.\n" " Optional --max-duration stops before starting the next iteration once\n" " the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n" ) @@ -1479,7 +1480,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) run_parser.add_argument( "--flags", @@ -1535,7 +1536,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) lead_parser.add_argument( "--flags", @@ -1604,7 +1605,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) agent_start.add_argument( "--flags", @@ -1837,7 +1838,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) task_parser.add_argument( "--flags", @@ -1911,7 +1912,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) ralph_parser.add_argument( "--flags", @@ -1982,7 +1983,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) science_parser.add_argument( "--flags", @@ -2033,7 +2034,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) foreach_parser.add_argument( "--flags", diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index a7c28b5..54a41cb 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -91,7 +91,7 @@ def lead( minutes: Check-in interval in whole minutes (>= 0). prompt: The original instruction prompt. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. backend: Agent backend to use ("codex" or "cursor"). diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 436425a..7a312bf 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -275,7 +275,7 @@ def task( a string check prompt. The string "None" skips verification. max_iterations: Maximum number of task iterations (0 means unlimited). cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. progress: Whether to show a tqdm progress bar with status updates. set_up: Optional setup prompt to run before the task. diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 95d8031..10cd170 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -30,6 +30,25 @@ def test_async_codex_command_uses_normal_mode_by_default(self): self.assertIn("features.fast_mode=false", command) self.assertNotIn("service_tier=fast", command) + def test_async_codex_command_uses_documented_yolo_flags(self): + command = _build_codex_command(None, True, None) + exec_index = command.index("exec") + self.assertIn("--dangerously-bypass-approvals-and-sandbox", command) + self.assertLess( + command.index("--dangerously-bypass-approvals-and-sandbox"), + exec_index, + ) + self.assertGreater(command.index("--json"), exec_index) + self.assertNotIn("--yolo", command) + + def test_async_codex_command_no_yolo_uses_auto_approval_mode(self): + command = _build_codex_command(None, False, None) + exec_index = command.index("exec") + self.assertLess(command.index("--ask-for-approval"), exec_index) + self.assertIn("never", command) + self.assertNotIn("--sandbox", command) + self.assertNotIn("--full-auto", command) + def test_async_codex_command_can_enable_fast_mode(self): command = _build_codex_command(None, True, None, fast=True) self.assertIn("service_tier=fast", command)