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/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 b93e4ae..b23726e 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,17 +45,88 @@ 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. +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 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 --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." ``` -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. + +```bash +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. + +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 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 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 +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` +section. + +Example task progress run: + +```bash +./examples/example_task_progress.sh +``` Show running sessions and their latest activity: @@ -62,101 +134,350 @@ 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/session id to stderr: + +```bash +codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." +``` + +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 +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, 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 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. + +```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. + +Start by checking the effective host/home pair and installing the scheduler: + +```bash +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 +background-safe `GH_TOKEN` automatically if your shell did not already export +`GH_TOKEN` or `GITHUB_TOKEN`. -Resume a session and print the thread id to stderr: +Start a goal-directed agent that decides for itself when it is done: ```bash -codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off." +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 +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 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." +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 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 +``` + +`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: + +```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. + +`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can +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 +active turn so far. + +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`. +`.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 +codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations 3 +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 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. +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 --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 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, 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. + +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 -### `agent(prompt, cwd=None, yolo=False, flags=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False, model=None, thinking=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. -- `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): 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`). +- `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=False, thread_id=None, flags=None)` +### `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. -- `__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. -- `flags` (str | None): extra CLI flags to pass to Codex. - -### `task(prompt, check=None, n=10, cwd=None, yolo=False, flags=None) -> str` +- `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`. +- `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` + +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, 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. +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, fast=False) -> 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` 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 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`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `task_result(prompt, check=None, n=10, cwd=None, yolo=False, 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, 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_attempts=10, cwd=None, yolo=False, thread_id=None, flags=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None, fast=False)` -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 +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() -> 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. -### `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. +- `thread_id` (str | None): thread/session id for the session. ### `TaskFailed` -Exception raised by `task()` when retries 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, backend=None, fast=False) -> 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 agent session. +- `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). + +### `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. -- 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). -- 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. +- 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. +- 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 +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/docs/agent-v1.md b/docs/agent-v1.md new file mode 100644 index 0000000..91ff917 --- /dev/null +++ b/docs/agent-v1.md @@ -0,0 +1,813 @@ +# 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. + +## `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: + +- `id`: stable identifier +- `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 +- `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`, `parent_id`, `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. + +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: +- 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` +- export the resolved `CODEXAPI_HOSTNAME` +- 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; `done` and `canceled` agents still process queued messages as a + one-off wake +- `wake` means run soon even if no heartbeat is due +- `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 + +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. + +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. + +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 whoami` +- `codexapi agent read` +- `codexapi agent book` +- `codexapi agent show` +- `codexapi agent status` +- `codexapi agent send` +- `codexapi agent wake` +- `codexapi agent pause` +- `codexapi agent resume` +- `codexapi agent cancel` +- `codexapi agent delete` +- `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` +- `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 +- `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 + +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. diff --git a/examples/example_task_progress.sh b/examples/example_task_progress.sh new file mode 100755 index 0000000..9a22da4 --- /dev/null +++ b/examples/example_task_progress.sh @@ -0,0 +1,4 @@ +#!/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/foreach_list.txt b/examples/foreach_list.txt new file mode 100644 index 0000000..908e612 --- /dev/null +++ b/examples/foreach_list.txt @@ -0,0 +1,4 @@ +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..96fb8fe --- /dev/null +++ b/examples/foreach_task_readonly.yaml @@ -0,0 +1,9 @@ +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. +on_success: "Acknowledge completion for {{item}}." +on_failure: | + Explain why the check failed for {{item}}. +tear_down: "No cleanup needed." diff --git a/examples/lead/README.md b/examples/lead/README.md new file mode 100644 index 0000000..01f78ab --- /dev/null +++ b/examples/lead/README.md @@ -0,0 +1,18 @@ +# Lead Example: Blocked Worker + +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 + +```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`, 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 new file mode 100644 index 0000000..88e3441 --- /dev/null +++ b/examples/lead/prompt.txt @@ -0,0 +1,2 @@ +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. diff --git a/examples/lead/run_example.sh b/examples/lead/run_example.sh new file mode 100755 index 0000000..93f26c7 --- /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)" + +LEAD_EXAMPLE_QUIET=1 "$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..ae41538 --- /dev/null +++ b/examples/lead/start_worker.sh @@ -0,0 +1,32 @@ +#!/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")" + +if [[ -z "${LEAD_EXAMPLE_QUIET:-}" ]]; then + 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/examples/task_file_readonly.yaml b/examples/task_file_readonly.yaml new file mode 100644 index 0000000..a3a613c --- /dev/null +++ b/examples/task_file_readonly.yaml @@ -0,0 +1,10 @@ +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. +on_success: "Acknowledge completion in one short sentence." +tear_down: "No cleanup needed." 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/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 7bceeb2..f93a31b 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.12.11" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" @@ -15,7 +15,11 @@ classifiers = [ "Operating System :: OS Independent", ] -dependencies = [] +dependencies = [ + "PyYAML>=6.0", + "gh-task>=0.1.7", + "tqdm>=4.64", +] [project.scripts] codexapi = "codexapi.cli:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..46aed8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyYAML>=6.0 +tqdm>=4.64 +gh-task>=0.1.7 diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 242a033..bd80db3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,15 +1,33 @@ -"""Minimal Python API for running the Codex CLI.""" +"""Minimal Python API for running agent CLIs.""" -from .agent import Agent, agent +from .agent import Agent, WelfareStop, agent, build_agent_flags +from .async_agent import AsyncAgent +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 +from .lead import lead __all__ = [ "Agent", + "AsyncAgent", + "ForeachResult", + "Pushover", + "build_agent_flags", + "quota_line", + "rate_limits", + "Ralph", + "Science", "Task", "TaskFailed", "TaskResult", + "WelfareStop", "agent", + "foreach", "task", "task_result", + "lead", ] -__version__ = "0.1.8" +__version__ = "0.12.11" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index d6585d2..8adad03 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -1,31 +1,111 @@ -"""Codex CLI wrapper used by the codexapi public interface.""" +"""Agent CLI wrapper used by the codexapi public interface.""" import json import os import shlex +import shutil import subprocess +from . import welfare + _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): + 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 _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) + env_var = "CURSOR_BIN" + label = "Cursor agent CLI" + merged = _merged_env(env) + path_value = None if merged is None else merged.get("PATH") + 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( + f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH." + ) -def agent(prompt, cwd=None, yolo=False, flags=None): - """Run a single Codex turn and return only the agent's message. + +def agent( + prompt, + cwd=None, + yolo=True, + flags=None, + include_thinking=False, + backend=None, + env=None, + fast=False, + model=None, + thinking=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 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"). + 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 = _run_codex(prompt, cwd, None, yolo, flags) + message, _thread_id, _usage = _run_agent( + prompt, + cwd, + None, + yolo, + flags, + include_thinking, + backend, + env, + fast, + model, + thinking, + ) 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. + """Stateful session wrapper that resumes the same conversation. Example: session = Agent() @@ -36,52 +116,134 @@ class Agent: def __init__( self, cwd=None, - yolo=False, + yolo=True, thread_id=None, flags=None, + welfare=False, + include_thinking=False, + backend=None, + env=None, + fast=False, + model=None, + thinking=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 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 + 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. + 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 self._flags = flags + self._welfare = welfare + self._include_thinking = include_thinking self.thread_id = thread_id + self._backend = backend + self._env = env + self._fast = fast + self._model = model + self._thinking = thinking + self.last_usage = {} def __call__(self, prompt): - """Send a prompt to Codex and return only the agent's message.""" - message, thread_id = _run_codex( + """Send a prompt to the agent backend and return the message.""" + if self._welfare: + prompt = welfare.append_instructions(prompt) + message, thread_id, usage = _run_agent( prompt, self.cwd, self.thread_id, self._yolo, self._flags, + self._include_thinking, + self._backend, + self._env, + self._fast, + self._model, + self._thinking, ) 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 -def _run_codex(prompt, cwd, thread_id, yolo, flags): +def _run_agent( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + 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, + 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, + model=None, + 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: command.extend(shlex.split(flags)) if cwd: @@ -97,6 +259,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags): 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() @@ -105,14 +268,141 @@ 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 _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: + return [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ] + return ["-c", "features.fast_mode=false"] -def _parse_jsonl(output): +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_command_prefix(env) + [ + "--trust", + ] + if cwd: + command.extend(["--workspace", os.fspath(cwd)]) + if thread_id: + 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"]) + + result = subprocess.run( + command, + input=prompt, + 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() + 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 messages = [] raw_lines = [] + usage = {} for line in output.splitlines(): line = line.strip() @@ -129,6 +419,10 @@ def _parse_jsonl(output): 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": @@ -142,4 +436,141 @@ 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, usage + return messages[-1], thread_id, usage + + +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, {} + + +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 + + +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 new file mode 100644 index 0000000..bd91ff7 --- /dev/null +++ b/src/codexapi/agents.py @@ -0,0 +1,2449 @@ +"""Durable long-running agent control plane.""" + +import json +import os +import random +import re +import signal +import shlex +import shutil +import socket +import string +import subprocess +import sys +import tempfile +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from hashlib import sha1 +from pathlib import Path + +import fcntl + +from .agent import Agent, _ensure_backend_available, _resolve_backend +from .pushover import Pushover + +_DEFAULT_HOME = "~/.codexapi" +_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." +) +_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" + " update: string (recommended; short first-person summary of this turn)\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"} +_STALE_MIN_SECONDS = 30 * 60 +_STALE_HEARTBEAT_MULTIPLIER = 3 +_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 picture: +- + +What is moving: +- + +What is not moving: +- + +Active tasks: +- + +Assumptions / ownership: +- + +Unexpected developments: +- + +Wider frame: +- + +Things I am curious about: +- + +Risks / watchpoints: +- + +Next decisive action: +- +""" + + +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(): + """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.""" + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if override: + return override + 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, + parent_ref=None, + stop_policy="until_done", + heartbeat_minutes=5, + backend=None, + yolo=True, + flags=None, + 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(): + 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) + 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) + 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) + + parent_id, parent_name = _parent_identity(home, parent_ref) + if created_by is None: + 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": "", + "rollout_path": "", + "backend": backend_name, + "yolo": bool(yolo), + "flags": flags or "", + "fast": bool(fast), + "cwd": cwd, + "env": session_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), + "parent_id": parent_id, + "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": "", + "update": "", + } + + _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(meta["prompt"])) + 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 [] + 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, child_map)) + 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.""" + home = _resolve_home(home) + 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"] + 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"] + 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) + snapshot["children"] = _agent_briefs(home, snapshot["child_ids"], child_map) + 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["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": "", + "turn_state": "missing", + "started_at": "", + "ended_at": "", + "cwd": snapshot.get("cwd") or "", + "progress": [], + "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"], + "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 + 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"]: + 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 + + +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 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, + "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( + { + "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, + "author": pending.get("author") or "user", + } + ) + items.sort(key=lambda item: item.get("timestamp") or "") + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "items": items[-limit:], + } + + +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(): + 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 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 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) + 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 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() + 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 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) + 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 + 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} + + +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, host) + 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 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() + 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): + """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(): + 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 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 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 _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") + 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) + 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, terminal_status) + 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" + 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], + "messages": [], + "status": "", + "reply": "", + "update": "", + "notify": "", + "error": "", + "continue": True, + "usage": {}, + } + try: + 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["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) + state["reply"] = response["reply"] + state["update"] = response["update"] + 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 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"]) + ) + 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["update"] = response["update"] + 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']}" + Pushover().send(title, response["notify"]) + except Exception as exc: + ended = utc_now() + 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"] = "" + 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) + 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: + outcome = runner(meta, session, prompt) + if not isinstance(outcome, dict): + raise TypeError("runner must return a dict") + return outcome + started = utc_now() + 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 = "" + 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": usage, + "rollout_path": rollout_path, + } + + +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") + update = payload.get("update") + 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 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(), + } + + +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(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']}", + f"Heartbeat minutes: {meta['heartbeat_minutes']}", + "", + f"Working directory: {meta['cwd']}", + f"Agentbook path: {book_path}", + "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): + lines.extend(["", "Original instructions:", meta["prompt"]]) + book = _ensure_agentbook_header(book_path, meta["prompt"], now) + if book.strip(): + 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: + 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") in ("paused", "done"): + 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 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"): + 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 _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" + _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, 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) + 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( + [item for item in queued if item.get("kind") == "send"] + ) + status = state.get("status") or "" + return { + "id": meta["id"], + "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"], + "heartbeat_minutes": meta["heartbeat_minutes"], + "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), + "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 "", + "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: + 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, 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(): + 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() + 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 _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 _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") + + +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 _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)) + 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 _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_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() + 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 "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return 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 + 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))) + + +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 _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(): + 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 + 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(): + 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" + 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: + 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 (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 + 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 _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 {} + last_event_at = "" + progress_events = [] + assistant_events = [] + tools = [] + tool_by_call_id = {} + ended_at = "" + 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") + 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, + "last_event_at": last_event_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}" + + +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 _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"], + 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") + + +def _remove_file(path): + try: + Path(path).unlink() + except FileNotFoundError: + return diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py new file mode 100644 index 0000000..edc2cf9 --- /dev/null +++ b/src/codexapi/async_agent.py @@ -0,0 +1,458 @@ +"""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, + _agent_config_flag_parts, + _codex_automation_flags, + _codex_fast_config, + _cursor_command_prefix, + _ensure_backend_available, + _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._errors: 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, + 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(): + 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, fast, model, thinking) + 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) + error_lines = list(self._errors) + 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, + 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, + "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": last_error, + "stderr": "\n".join(stderr_lines), + "errors": error_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) + 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": + 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, fast=False, model=None, thinking=None): + if backend == "codex": + 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, model=None, thinking=None): + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ + "exec", + "--json", + "--color", + "never", + "--skip-git-repo-check", + ] + command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) + 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, 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"]) + 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, 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: + return final_output + if status == "done": + return "Finished" + if status == "canceled": + return "Canceled" + return "Running" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index dd66ff0..3e3e790 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -3,25 +3,84 @@ import os import re import select +import shlex import shutil import subprocess import sys +import time import termios import tty from datetime import datetime from pathlib import Path +from . import __version__ from .agent import Agent, agent -from .ralph import cancel_ralph_loop, run_ralph_loop -from .task import TaskFailed, task +from .agents import ( + codexapi_home, + control_agent, + cron_status as agent_cron_status, + current_hostname, + delete_agent as delete_managed_agent, + install_cron as install_agent_cron, + list_agents as list_managed_agents, + 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, + 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, +) +from .foreach import foreach +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 +from .rate_limits import quota_line +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}" ) +_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)([smhdSMHD]?)\s*$") _TAIL_BYTES = 256 * 1024 _TAIL_MAX_BYTES = 4 * 1024 * 1024 _TAIL_MIN_LINES = 200 +_PROJECT_LOOP_SLEEP = 30 _ROLL_OUT_PREFIX = "rollout-" +_TASK_TEMPLATE = ( + "prompt: |\n" + " Main task prompt. Required. Use {{item}} for per-item values.\n" + " Describe what the agent 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", @@ -38,11 +97,21 @@ "in": "IN", "out": "OUT", "turn": "TURN", + "turns": "NTRN", "model": "MODEL", "effort": "EFF", "perm": "PERM", "cwd": "CWD", } +_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): @@ -55,12 +124,284 @@ 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.") + 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 "" return " ".join(text.replace("\r", " ").split()) +def _print_managed_agent_list(items): + if not items: + print("No agents.") + return + 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.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) + 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:<9} {policy:<4} {host:<12} {queued_messages:>4} {queued_commands:>4} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {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" + author = item.get("author") or "" + if author: + print(f"[{stamp}] {kind} {author}:") + else: + print(f"[{stamp}] {kind}:") + print(item.get("text") or "") + 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"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 '-'}") + 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)}") + 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: + 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"Update: {final_json.get('update') 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()}") + print(f"Host override: {override or '-'}") + 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: + status = agent_cron_status() + 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 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. " + "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 _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) + 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 "" + 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 + return None + + +def _print_managed_agent_show(result): + meta = result["meta"] + state = result["state"] + 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 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 " + 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"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'))}") + 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 _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(): + 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 "" @@ -81,15 +422,140 @@ 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 "" + 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": + 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 _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 _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 "-" + 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 "" + 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) + 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 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): @@ -118,6 +584,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 @@ -221,19 +708,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]) @@ -243,8 +731,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 @@ -270,7 +764,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: @@ -311,6 +809,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 "-" @@ -350,6 +868,7 @@ def _summarize_session(path, mtime): total_usage = None meta = {} subagent = None + turns = _count_turns(path) for line in _tail_lines(path): try: @@ -471,6 +990,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, } @@ -590,6 +1110,7 @@ def _layout_columns(width, id_width, show): ("in", ">"), ("out", ">"), ("turn", ">"), + ("turns", ">"), ] widths = { "id": id_width, @@ -598,6 +1119,7 @@ def _layout_columns(width, id_width, show): "in": 7, "out": 7, "turn": 7, + "turns": 5, } mins = {} @@ -670,6 +1192,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 "-" @@ -688,6 +1212,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)), @@ -804,6 +1329,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") @@ -867,149 +1423,1083 @@ 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" - " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n" - " each iteration (no shared context).\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 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 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" ) parser = argparse.ArgumentParser( prog="codexapi", - description="Run Codex via the codexapi wrapper.", - epilog=ralph_help, - formatter_class=argparse.RawDescriptionHelpFormatter, + 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 = _add_subparser( + subparsers, + "run", + "Run an agent prompt.", + ) + run_parser.add_argument( "prompt", nargs="?", help="Prompt to send. Use '-' or omit to read from stdin.", ) - parser.add_argument( - "--task", + 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( + "--fast", action="store_true", - help="Run in task mode with verification retries.", + help="Use Codex fast mode (Codex backend only; default: normal mode).", ) - parser.add_argument( - "--check", - help="Optional check prompt for --task. Defaults to the task prompt.", + run_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) - 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( "--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).", ) - parser.add_argument( + run_parser.add_argument( + "--include-thinking", + action="store_true", + help="Return all agent messages joined together (Codex only).", + ) + + lead_parser = _add_subparser( + subparsers, + "lead", + "Periodically check in to lead long-running work.", + ) + lead_parser.add_argument( + "minutes", + type=int, + help="Check-in interval in minutes (integer, >= 0).", + ) + lead_parser.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + 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 agent session.") + lead_parser.add_argument( + "--backend", + 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).", + ) + 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="Use auto approvals instead of dangerous no-sandbox automation.", + ) + lead_parser.add_argument( + "--flags", + 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.", ) - 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", + + agent_parser = _add_subparser( + subparsers, + "agent", + "Manage durable long-running agents.", + ) + agent_subparsers = agent_parser.add_subparsers(dest="agent_command") + + agent_start = _add_subparser( + agent_subparsers, + "start", + "Create a durable agent and return immediately unless --wait is set.", + ) + 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( + "--parent", + help="Optional parent agent id, unique prefix, or name.", + ) + 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( + "--fast", action="store_true", - help="Run a Ralph loop that repeats the same prompt each iteration.", + help="Use Codex fast mode (Codex backend only; default: normal mode).", ) - parser.add_argument( + agent_start.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + agent_start.add_argument( + "--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.", + ) + + _add_subparser( + agent_subparsers, + "list", + "List durable agents in this CODEXAPI_HOME.", + ) + _add_subparser( + agent_subparsers, + "whoami", + "Show the effective host and CODEXAPI_HOME for agents.", + ) + + agent_run = _add_subparser( + agent_subparsers, + "run", + argparse.SUPPRESS, + ) + agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) + + agent_show = _add_subparser( + agent_subparsers, + "show", + "Show one durable agent.", + ) + agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_status = _add_subparser( + agent_subparsers, + "status", + "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 = _add_subparser( + agent_subparsers, + "read", + "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_book = _add_subparser( + agent_subparsers, + "book", + "Show the current agentbook for one agent.", + ) + agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_send = _add_subparser( + agent_subparsers, + "send", + "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 and return immediately unless --wait is set."), + ("pause", "Pause an agent."), + ("resume", "Resume a paused agent and return immediately unless --wait is set."), + ("cancel", "Cancel an agent."), + ): + 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"): + subparser.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the command.", + ) + + agent_recover = _add_subparser( + agent_subparsers, + "recover", + "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 = _add_subparser( + agent_subparsers, + "set-heartbeat", + "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 = _add_subparser( + agent_subparsers, + "delete", + "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.", + ) + + _add_subparser( + agent_subparsers, + "tick", + "Process due agents for the current host.", + ) + _add_subparser( + agent_subparsers, + "install-cron", + "Install or update the cron entry for this CODEXAPI_HOME.", + ) + _add_subparser( + agent_subparsers, + "uninstall-cron", + "Remove the cron entry for this CODEXAPI_HOME.", + ) + + _add_subparser( + subparsers, + "tick", + "Run one full background tick.", + ) + + task_parser = _add_subparser( + subparsers, + "task", + "Run a task with verification retries.", + ) + task_parser.add_argument( + "-f", + "--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( + "-p", + "--project", + help="When using -p, also pass -n agent_name TASK_FILE1 [TASK_FILE2 ...].", + ) + task_parser.add_argument( + "-s", + "--status", + default="Ready", + help="Status name to take from when using --project (default: Ready).", + ) + task_parser.add_argument( + "-n", + "--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="*", + help="Prompt to send (no --project) or task files (with --project).", + ) + task_parser.add_argument( + "--check", + help="Optional check prompt. Defaults to the task prompt.", + ) + task_parser.add_argument( "--max-iterations", type=int, default=None, - help="Max iterations for --ralph (0 means unlimited).", + help=( + "Max agent iterations (0 means unlimited). " + f"Defaults to {DEFAULT_MAX_ITERATIONS}." + ), ) - parser.add_argument( + 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( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) + task_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + task_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", + ) + task_parser.add_argument( + "--quiet", + 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 = _add_subparser( + subparsers, + "ralph", + "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.", + ) + ralph_parser.add_argument( + "--max-iterations", + type=int, + 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.", + ) + ralph_parser.add_argument( "--completion-promise", - help="Promise text for --ralph to match in ....", + help="Promise text to match in ....", ) - parser.add_argument( + ralph_fresh_group = ralph_parser.add_mutually_exclusive_group() + ralph_fresh_group.add_argument( "--ralph-fresh", action="store_true", - help="With --ralph, start each iteration with a fresh Agent context.", + dest="ralph_fresh", + default=None, + help="Start each iteration with a fresh Agent context (default).", ) - parser.add_argument( - "--ralph-cancel", + 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 agent session.") + ralph_parser.add_argument( + "--backend", + 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", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + ralph_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", + ) + + science_parser = _add_subparser( + subparsers, + "science", + "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( + "--max-duration", help=( - "Cancel a Ralph loop by removing .codexapi/ralph-loop.local.md " - "(respects --cwd)." + "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", + 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 agent session.") + science_parser.add_argument( + "--backend", + 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", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + science_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", + ) + + foreach_parser = _add_subparser( + subparsers, + "foreach", + "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_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, + help="Limit parallelism to N.", + ) + 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( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) + foreach_parser.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + foreach_parser.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", + ) + + create_parser = _add_subparser( + subparsers, + "create", + "Create a task file template.", + ) + create_parser.add_argument( + "filename", + help="Filename for the new task file.", + ) + + reset_parser = _add_subparser( + subparsers, + "reset", + "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.", + ) + + _add_subparser( + subparsers, + "top", + "Show running Codex sessions.", + ) + _add_subparser( + subparsers, + "limit", + "Show Codex rate limits.", + ) 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." + 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) + 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, + fast=args.fast, + ) + 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) + _warn_agent_scheduler_missing() + 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 == "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 + 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.") + _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["waited"] = bool(args.wait) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) + if args.wait: + 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, ) - print(cancel_ralph_loop(args.cwd)) + result["waited"] = bool(args.wait) + 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"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + result["waited"] = False + 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.") + 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( + 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 + 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 == "tick": + print(json.dumps(_system_tick(), indent=2, sort_keys=True)) 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.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 + if args.command == "limit": + print(quota_line()) + return + + 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, + args.n, + args.cwd, + args.yolo, + args.flags, + args.backend, + args.fast, ) - if args.task and (args.thread_id or args.print_thread_id): - raise SystemExit("--thread-id/--print-thread-id are not supported with --task.") + if result.failed: + raise SystemExit(1) + return - 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 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 == "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.") + 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: + 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.") + 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 + + if args.loop: + while True: + try: + task_runner = GhTaskRunner( + args.project, + args.name, + args.task_args, + args.status, + args.only_matching, + args.cwd, + args.yolo, + args.flags, + args.backend, + args.fast, + ) + 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 + 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) + else: + try: + task_runner = GhTaskRunner( + args.project, + args.name, + args.task_args, + args.status, + args.only_matching, + args.cwd, + args.yolo, + args.flags, + args.backend, + args.fast, + ) + 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) + return + + if args.command == "task" and args.task_file: + 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) + if not task_def_uses_item(task_def): + 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: + raise SystemExit("--max-iterations is not allowed with -f.") + task_runner = TaskFile( + args.task_file, + args.item, + cwd=args.cwd, + yolo=args.yolo, + thread_id=None, + flags=args.flags, + backend=args.backend, + fast=args.fast, + ) + result = task_runner(progress=not args.quiet) + if not result.success: + raise SystemExit(1) + return + prompt_source = None + prompt = None + 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" and prompt is None: + prompt = _read_prompt(prompt_source) exit_code = 0 + message = None - 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( + Ralph( prompt, args.cwd, args.yolo, args.flags, - max_iterations, + args.max_iterations, args.completion_promise, args.ralph_fresh, - ) + args.backend, + args.fast, + )() return - if args.task: - check = args.check if args.check is not None else prompt + 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, + args.yolo, + args.flags, + args.max_iterations, + args.completion_promise, + args.ralph_fresh, + max_duration_seconds, + args.backend, + args.fast, + )() + return + if args.command == "lead": + 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.") + leadbook = False if args.no_leadbook else args.leadbook + lead( + args.minutes, + prompt, + args.cwd, + args.yolo, + args.flags, + leadbook, + args.backend, + args.fast, + ) + except KeyboardInterrupt: + raise SystemExit(130) + except Exception as exc: + raise SystemExit(str(exc) or "lead failed") from None + return + 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.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: + raise SystemExit("--max-iterations must be >= 0.") + check = args.check try: - message = task( + 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, - cwd=args.cwd, - yolo=args.yolo, - flags=args.flags, + args.max_iterations, + args.cwd, + args.yolo, + args.flags, + not args.quiet, + backend=args.backend, + fast=args.fast, ) except TaskFailed as exc: - message = exc.summary exit_code = 1 else: use_session = args.thread_id or args.print_thread_id @@ -1019,14 +2509,26 @@ def main(argv=None): args.yolo, args.thread_id, args.flags, + include_thinking=args.include_thinking, + backend=args.backend, + fast=args.fast, ) 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, + args.backend, + fast=args.fast, + ) - print(message) + if message is not None: + print(message) if exit_code: raise SystemExit(exit_code) diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py new file mode 100644 index 0000000..b78676a --- /dev/null +++ b/src/codexapi/foreach.py @@ -0,0 +1,236 @@ +"""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 TaskFile + +_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, + 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) + 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_file, + lines, + ends_with_newline, + list_file, + cwd, + yolo, + flags, + backend, + fast, + 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_file, + lines, + ends_with_newline, + list_file, + cwd, + yolo, + flags, + backend, + fast, + 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 + iterations = None + max_iterations = None + try: + task = TaskFile( + task_file, + item, + cwd=cwd, + yolo=yolo, + thread_id=None, + flags=flags, + backend=backend, + fast=fast, + ) + max_iterations = task.max_iterations + result = task() + success = result.success + 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(iterations, max_iterations) + 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/gh_integration.py b/src/codexapi/gh_integration.py new file mode 100644 index 0000000..8731f76 --- /dev/null +++ b/src/codexapi/gh_integration.py @@ -0,0 +1,349 @@ +import logging +import re +import time +from pathlib import Path + +from tqdm import tqdm + +from gh_task.errors import TakeError +from gh_task.project import Project, UPDATE_STATUS_MUTATION + +from .taskfile import TaskFile + + +_logger = logging.getLogger(__name__) + +_PROGRESS_HEADER = "## Progress" +_SUCCESS_LABEL = "✓" +_FAILURE_LABEL = "⨉" +_SUCCESS_COLOR = "2da44e" +_FAILURE_COLOR = "d73a4a" +_OWNER_PREFIX = "owner:" + + +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 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: + 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 _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 "" + 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, + backend=None, + fast=False, + ): + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend, fast) + 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="Ready", + only_matching=None, + cwd=None, + 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)) + 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) + 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) + 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, + backend, + fast, + ) + + def __call__(self, progress=False): + return self.task(progress=progress) diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py new file mode 100644 index 0000000..54a41cb --- /dev/null +++ b/src/codexapi/lead.py @@ -0,0 +1,492 @@ +"""Periodic lead loop for long-running agent work. + +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 +updated before the agent responds. +""" + +import hashlib +import json +import os +import re +import sys +import time +from datetime import datetime + +from .agent import Agent +from .pushover import Pushover + +_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 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 " + "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" + " status: string (one line)\n" + " continue: boolean\n" + " comments: string (optional)\n" + "To stop this lead loop, set continue to false." +) +_LEADBOOK_INSTRUCTIONS = ( + "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 + +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: +- +""" +_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( + minutes, + prompt, + cwd=None, + yolo=True, + flags=None, + leadbook=None, + backend=None, + fast=False, +): + """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 agent session. + 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"). + fast: Enable Codex fast mode. Defaults to normal mode. + + Returns: + The last parsed JSON status object. + """ + if not isinstance(minutes, int): + raise TypeError("minutes must be an integer") + 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") + + interval = minutes * 60 + 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) + leadbook_path = _resolve_leadbook_path(leadbook, cwd) + if leadbook_path: + _ensure_leadbook(leadbook_path) + + 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") + 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"[lead {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"Lead 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 + + 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): + lines = [] + + if tick == 1: + lines.extend( + [ + _WELCOME_PROMPT.format(minutes=minutes), + "", + ] + ) + + lines.extend( + [ + f"Check-in {tick}.", + f"Local time now: {now}", + ] + ) + if elapsed is not None: + lines.append( + "Time since last check-in: " + f"{_format_minutes_seconds(elapsed)} ({int(round(elapsed))}s)" + ) + lines.extend( + [ + "", + "A reminder: your instructions are:", + prompt.strip(), + ] + ) + leadbook_block = _leadbook_block(leadbook_path, leadbook) + if leadbook_block: + lines.extend(["", leadbook_block]) + lines.extend(["", _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 _json_retry_prompt(prompt, tick, error, output): + snippet = _snippet(output, 600) + lines = [ + f"Your last message (check-in {tick}) was not valid JSON.", + f"Error: {error}", + "", + "Here is your previous output (truncated):", + snippet, + "", + "Please try again and 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.", + "", + _JSON_INSTRUCTIONS, + ] + return "\n".join(lines).strip() + + +def _format_title(prompt): + text = _single_line(prompt).strip() or "codexapi lead" + if len(text) > 60: + text = text[:57] + "..." + return f"Lead: {text}" + + +def _format_stop_message(tick, now, result): + status = _single_line(result.get("status") or "").strip() + header = f"Lead stopped at check-in {tick} ({now})." + if status: + header = f"{header} {status}" + comments = (result.get("comments") or "").strip() + if comments: + return f"{header}\n{comments}" + return header + + +def _leadbook_block(path, leadbook): + if not path: + return "" + snippet = _book_excerpt(leadbook, _LEADBOOK_LIMIT, _LEADBOOK_HEADER_LIMIT, _LEADBOOK_TAIL_LIMIT) + return "\n".join( + [ + f"Leadbook path: {path}", + _LEADBOOK_INSTRUCTIONS, + "", + "Leadbook (header + latest notes):", + 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 _hash_text(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +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 _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 + 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"[lead {tick} {now}{delta}] {status} (continue={cont})".rstrip() + print(line) + comments = result.get("comments") or "" + if comments.strip(): + print(comments.rstrip()) diff --git a/src/codexapi/pushover.py b/src/codexapi/pushover.py new file mode 100644 index 0000000..6dea2e5 --- /dev/null +++ b/src/codexapi/pushover.py @@ -0,0 +1,189 @@ +"""Pushover notification helper.""" + +import json +import os +import sys +import threading +import urllib.error +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 + +_STARTUP_MESSAGE = ( + "Pushover user and app keys read, notifications for task/science/lead 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 = _append_quota_line(message_text) + 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) + + +def _append_quota_line(message): + line = quota_line() + if not line: + return message + return f"{message}\n{line}" diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 02b8ea5..c9aad33 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -1,183 +1,262 @@ -"""Ralph Wiggum-style loop for Codex runs.""" +"""Ralph Wiggum-style loop for agent runs.""" import os import re 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" _PROMISE_RE = re.compile(r"(.*?)", re.DOTALL) -def run_ralph_loop( - prompt, - cwd=None, - yolo=False, - flags=None, - max_iterations=0, - completion_promise=None, - fresh=False, -): - """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 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`. - """ - 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") +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, + backend=None, + fast=False, + ): + 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 + self.backend = backend + self.fast = fast + self.include_thinking = True + + 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 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 + + 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.", + "Welfare stop: agent may output MAKE IT STOP (first non-empty line).", "", - "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", "", ] ) ) + 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", + " - 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", + "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.", + "", + ] + ) + ) - 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) - 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}" + 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 = self._new_agent() + elif runner is None: + runner = self._new_agent() + + prompt = self.build_prompt(iteration) + 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" + 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, ) - 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: + 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 _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, ) - 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) + 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): @@ -319,12 +398,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/rate_limits.py b/src/codexapi/rate_limits.py new file mode 100644 index 0000000..f94d517 --- /dev/null +++ b/src/codexapi/rate_limits.py @@ -0,0 +1,112 @@ +"""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 + preferred = 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 + if _is_primary_limit(rate_data): + preferred = rate_data + except OSError: + return None + 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(): + """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" diff --git a/src/codexapi/science.py b/src/codexapi/science.py new file mode 100644 index 0000000..21d5583 --- /dev/null +++ b/src/codexapi/science.py @@ -0,0 +1,413 @@ +"""Science-mode Ralph loop with logbook output and metric notifications.""" + +import json +import os +import sys +import time +from datetime import datetime, timezone + +from .agent import agent +from .pushover import Pushover +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 = ( + "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 (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 " + "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" +_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. " + "Always include the absolute value under value." + "\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): + 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 _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, + max_duration_seconds=0, + backend=None, + fast=False, + ): + 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}" + super().__init__( + prompt, + cwd, + yolo, + flags, + max_iterations, + completion_promise, + fresh, + backend, + fast, + ) + self.include_thinking = True + 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 = 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 + self._backend = backend + self._fast = fast + + def hook_before_loop(self): + super().hook_before_loop() + 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: + 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._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) + 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._pushover.send(self._run_title, 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) + + def _extract_and_notify(self, message): + prompt = _build_metrics_prompt(self._task, message, self._best_metrics) + try: + output = self._agent(prompt) + 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 = self._agent(prompt) + except Exception: + title = "" + title = _single_line(title).strip() + if not title: + 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 + 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): + 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 _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 _fallback_title(task): + text = _single_line(task or "").strip() + if not text: + return "Science run" + return text[:77] + "..." if len(text) > 80 else text + + +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/src/codexapi/task.py b/src/codexapi/task.py index cac8d7f..7a312bf 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -1,29 +1,97 @@ -"""Task wrapper for running Codex Agent flows with checkers.""" +"""Task wrapper for running agent flows with checkers.""" import json import logging +import time -from .agent import Agent +from .agent import Agent, WelfareStop, agent +from .pushover import Pushover +from tqdm import tqdm _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 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." ) -_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" + "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 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" + "```" ) -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_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): @@ -45,11 +113,110 @@ def _check_result(output): return success, reason.strip() +def _estimate_result(output): + try: + data = json.loads(output) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Estimate returned invalid JSON: {exc}" + ) from exc + + if not isinstance(data, dict): + raise RuntimeError("Estimate JSON must be an object.") + + 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'.") + + remaining = int(round(remaining)) + if remaining < 0: + remaining = 0 + + return remaining, _single_line(summary) + + +def _single_line(text): + if not text: + return "" + return " ".join(text.replace("\r", " ").split()) + + +def _format_elapsed(seconds): + if seconds < 0: + seconds = 0 + seconds = int(round(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours}h{minutes:02d}m{seconds:02d}s" + + +def _format_turns(iteration, total): + if total: + width = len(str(total)) + total_text = str(total) + else: + width = len(str(iteration)) + total_text = "∞" + if width < 1: + width = 1 + iteration_text = f"{iteration:0{width}d}" + 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, + backend=None, + fast=False, +): + estimate_prompt = _build_estimate_prompt( + prompt, + agent_output or "", + check_output or "", + previous_total, + ) + 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 ( - "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." ) @@ -59,111 +226,147 @@ 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 +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=False, + yolo=True, flags=None, + progress=False, + set_up=None, + tear_down=None, + on_success=None, + on_failure=None, + backend=None, + fast=False, ): """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. - cwd: Optional working directory for the Codex session. - yolo: Whether to pass --yolo to Codex. - flags: Additional raw CLI flags to pass to Codex. + 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 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. + 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"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: 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, check, n, cwd, yolo, flags) + result = task_result( + prompt, + check, + max_iterations, + cwd, + yolo, + flags, + progress, + set_up, + tear_down, + on_success, + on_failure, + backend, + fast, + ) 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( prompt, check=None, - n=10, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, - yolo=False, + yolo=True, flags=None, + progress=False, + set_up=None, + tear_down=None, + on_success=None, + on_failure=None, + backend=None, + fast=False, ): - """Run a prompt with optional checker-driven retries and return TaskResult.""" - if check is False: - runner = Agent(cwd, yolo, None, flags) - summary = runner(prompt) - return TaskResult(True, summary, 1, None, runner.thread_id) - if check is None: - check = _default_check(prompt) - if not isinstance(check, str): - raise TypeError("check must be a string or False") - if n < 0: - raise ValueError("n must be >= 0") + """Run a prompt with optional checker-driven retries and return TaskResult. - runner = Agent(cwd, yolo, None, flags) - checker = Agent(cwd, yolo, None, flags) + The runner keeps a single session. Each verification iteration uses a fresh, + stateless agent call. When progress is True, show progress updates each round. - runner(prompt) - check_prompt = _build_check_prompt(check) + Hook strings mirror task file keys: set_up, tear_down, on_success, on_failure. + """ + 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") - for attempt in range(n + 1): - success, reason = _check_result(checker(check_prompt)) - 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, - ) - 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, + backend=backend, + fast=fast, + ) + return runner(progress=progress) 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 @@ -171,7 +374,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}" @@ -180,7 +383,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 @@ -193,23 +396,52 @@ class Task: def __init__( self, prompt, - max_attempts=10, + max_iterations=DEFAULT_MAX_ITERATIONS, cwd=None, - yolo=False, + yolo=True, thread_id=None, flags=None, + backend=None, + fast=False, ): - if max_attempts < 1: - raise ValueError("max_attempts must be >= 1") + 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.agent = Agent( - cwd, - yolo, - thread_id, - flags, - ) + self.last_output = None + self.last_check_output = None + self.check_skipped = False + self.check_text = None + 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() + 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.""" @@ -217,11 +449,34 @@ def set_up(self): def tear_down(self): """Delete the directory etc.""" - def check(self): - """ Check if the task is done, return a string describing the problems if not. - This can be any combination of running tests, python code or running an agent - with a specific prompt in self.cwd. - """ + def check(self, output=None): + """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 = _call_agent( + check_prompt, + self.cwd, + self._yolo, + self._flags, + self._backend, + self._fast, + ) + 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.""" @@ -229,65 +484,286 @@ 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, + 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 ( - "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. 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 _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, + backend=self._backend, + fast=self._fast, + ), + 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): + 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, 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() + progress_updates = progress or self._progress_updates + self._progress_enabled = progress + start_time = time.monotonic() + self._progress_start = start_time + if progress_updates: + 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) + 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() + + # Try correcting it up to max_iterations times + error = None + while True: + iteration += 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)) - if debug: - _logger.debug("Fix output: %s", output) - else: - # otherwise get a summary of what was done and run on_success + + if progress_updates: + check_output = self.last_check_output + if self.check_skipped: + check_output = "Verification skipped." + progress_data = None + estimate_result, estimate_error = self._estimate_progress( + self.last_output or "", + check_output or "", + ) + 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: _logger.debug("Success summary: %s", summary) - result = TaskResult(True, summary, attempt + 1, error, self.agent.thread_id) + result = TaskResult( + True, + summary, + iteration, + None, + self.agent.thread_id, + ) self.on_success(result) + self.notify_pushover(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) + 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, + iteration, + error, + 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 + 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() + if self._progress_bar is not None: + self._progress_bar.close() + + +class AutoTask(Task): + """Task subclass that maps prompt strings onto Task hooks.""" + + def __init__( + self, + prompt, + check=None, + max_iterations=DEFAULT_MAX_ITERATIONS, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + set_up=None, + tear_down=None, + 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") + if max_iterations < 0: + raise ValueError("max_iterations must be >= 0") + super().__init__( + prompt, + max_iterations, + cwd, + yolo, + thread_id, + flags, + backend, + fast, + ) + 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: + _call_agent( + text, + self.cwd, + self._yolo, + self._flags, + self._backend, + self._fast, + ) + + 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 new file mode 100644 index 0000000..7aa9071 --- /dev/null +++ b/src/codexapi/taskfile.py @@ -0,0 +1,129 @@ +"""Load YAML task files and map them onto Task hooks.""" + +import yaml + +from .task import AutoTask + +_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'.") + + 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")), + "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")), + "max_iterations": max_iterations, + } + + +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) + + +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.""" + + def __init__( + self, + path, + item=None, + max_iterations=None, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + backend=None, + fast=False, + ): + 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"], + backend=backend, + fast=fast, + ) + 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"], + backend=backend, + fast=fast, + ) 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 + diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py new file mode 100644 index 0000000..10cd170 --- /dev/null +++ b/tests/test_agent_backend.py @@ -0,0 +1,157 @@ +import json +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, build_agent_flags +from codexapi.async_agent import _build_codex_command, _build_cursor_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_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) + 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( + [ + 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 new file mode 100644 index 0000000..06cb2aa --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,2013 @@ +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 +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi import __version__ +from codexapi.agents import ( + _build_wake_prompt, + _codex_rollout_usage, + _tick_lock_path, + _try_lock, + _remove_cron_line, + _upsert_cron_line, + control_agent, + cron_installed, + cron_status, + delete_agent, + format_utc, + install_cron, + nudge_agent, + read_agent, + read_agentbook, + recover_agent, + render_cron_line, + send_agent, + set_agent_heartbeat, + show_agent, + start_agent, + status_agent, + tick, + uninstall_cron, + write_tick_wrapper, +) +from codexapi.cli import ( + _print_managed_agent_identity, + _print_managed_agent_list, + _print_managed_agent_show, + _print_managed_agent_status, + main as cli_main, +) +from codexapi.lead import _leadbook_block + + +@contextmanager +def _temp_home(): + with tempfile.TemporaryDirectory() as tmpdir: + with patch.dict(os.environ, {"CODEXAPI_HOME": tmpdir, "USER": "tester"}, clear=False): + 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() + 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_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 + + 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") + 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_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") + 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): + cli_main(["agent", "book", agent["id"]]) + text = output.getvalue() + 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("Wake mode: continuation", 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.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: + 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("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( + [ + "# 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 { + "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 = [] + + 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") + + 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) + 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"], "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_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), + start + timedelta(seconds=2), + 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 = [] + + 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_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) + 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") + 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) + + 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", + 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 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_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 = [] + + 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]) + + 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_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 = [] + + 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) + + 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) + + 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: + 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_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) + + 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("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()) + + 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("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) + self.assertIn("msgs=1", text) + + def test_cli_start_warns_when_cron_missing(self): + with _temp_home() as 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", "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_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() + 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): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + 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", "send", agent["id"], "status"]) + 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["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): + 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", "--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") + 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) + + 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") + 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) + 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") + 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() diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py new file mode 100644 index 0000000..f31cc2e --- /dev/null +++ b/tests/test_async_agent.py @@ -0,0 +1,219 @@ +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}, + ) + + 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() 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() diff --git a/tests/test_science.py b/tests/test_science.py new file mode 100644 index 0000000..1125418 --- /dev/null +++ b/tests/test_science.py @@ -0,0 +1,98 @@ +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, + backend=None, + ): + 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() diff --git a/tests/test_task_progress.py b/tests/test_task_progress.py new file mode 100644 index 0000000..dae8e1c --- /dev/null +++ b/tests/test_task_progress.py @@ -0,0 +1,79 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, 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() + 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) + 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() + 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) + self.assertEqual(result.iterations, 1) + self.assertTrue(task.set_up_called) + self.assertTrue(task.tear_down_called) + + +if __name__ == "__main__": + unittest.main()