From 209d2dadd238e5825b7a1a78789887726d2f70a4 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 01:32:02 +0100 Subject: [PATCH 01/31] Add codexapi agent v1 design spec --- docs/agent-v1.md | 758 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 docs/agent-v1.md diff --git a/docs/agent-v1.md b/docs/agent-v1.md new file mode 100644 index 0000000..16026ce --- /dev/null +++ b/docs/agent-v1.md @@ -0,0 +1,758 @@ +# codexapi agent V1 + +## Purpose + +`codexapi agent` is a long-term fire-and-forget orchestration layer built on top +of the existing agent, task, science, and lead primitives. + +The V1 goal is not to invent a new kind of coding agent. The goal is to make a +durable agent that can: + +- keep working for days +- survive sleep, reboot, and missed scheduler runs +- be inspected and controlled from the CLI +- accept messages while it is running +- delegate coding work to `codexapi task` or `codexapi science` +- escalate to the user when needed + +The design is intentionally simple. It uses durable filesystem state plus one +periodic scheduler entry per `CODEXAPI_HOME`. + +## Non-Goals + +V1 does not try to solve everything. + +- No daemon is required. +- No SSH is required. +- No cross-host migration or "teleportation" of running agents. +- No separate task-agent and watcher-agent runtimes. +- No catch-up replay of missed heartbeat ticks. +- No dependence on real cron in automated tests. +- No shared append-only logs written by multiple hosts. + +These are deliberate omissions. They keep the system small, portable, and easy +to reason about. + +## Top-Level Model + +An agent is a durable record plus a periodic wake mechanism. + +- There is one agent type. +- Each agent has a `stop_policy`. +- Each agent belongs to exactly one `CODEXAPI_HOME`. +- Each agent is owned by exactly one hostname. +- Only the owning hostname may wake and run the agent. +- Any host that can see the shared filesystem may inspect the agent and queue + commands for it. + +The agent's durable truth is the state stored under `CODEXAPI_HOME`, not a live +backend process. Each wake starts a fresh backend process and resumes from the +saved thread id when available. + +## `CODEXAPI_HOME` + +`CODEXAPI_HOME` is the root of a complete agent control plane. + +Default: + +```text +~/.codexapi +``` + +Override: + +```text +CODEXAPI_HOME=/path/to/home +``` + +Why this exists: + +- It isolates live state from tests. +- It allows multiple independent codexapi installations on one machine. +- It allows a shared filesystem setup without forcing all state into one global + namespace. + +Two different `CODEXAPI_HOME` values are two different systems. They do not see +each other's agents, locks, scheduler wrappers, or cron entries. + +## Agent Model + +Each agent stores at least: + +- `id`: stable identifier +- `name`: human-readable unique name within the home +- `created_at`: UTC timestamp +- `created_by`: user name or parent agent name +- `hostname`: owning host for execution +- `cwd`: working directory +- `prompt`: original instruction text +- `stop_policy`: `until_done` or `until_stopped` +- `status`: current lifecycle state +- `thread_id`: backend resume id, or empty +- `heartbeat_minutes`: heartbeat interval +- `last_wake_at`: last attempted wake time in UTC +- `last_success_at`: last completed wake time in UTC +- `next_wake_at`: next heartbeat due time in UTC +- `wake_requested_at`: durable "run soon" flag for queued commands/messages +- `unread_message_count`: messages not yet folded into a wake +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` +- `child_ids` +- `last_error`: most recent failure summary, if any +- `activity`: short status text for `agent list` + +V1 uses one agent type with one explicit lifecycle hint: + +- `stop_policy=until_done`: agent is expected to decide when it is finished +- `stop_policy=until_stopped`: agent is expected to keep running until stopped + +This keeps the runtime unified while preserving a small but important semantic +difference for scheduling and UI. + +## Lifecycle States + +V1 keeps the state model small: + +- `ready`: can be woken when due +- `running`: a wake is currently in progress +- `paused`: do not wake until resumed +- `done`: completed by the agent's own judgment +- `canceled`: stopped by an explicit command +- `error`: last wake failed and the agent needs attention or another wake + +Why these states: + +- `ready` and `running` are enough for normal operation +- `paused`, `done`, and `canceled` are user-visible terminal or semi-terminal + control states +- `error` makes failures explicit without inventing a richer failure taxonomy + +## Filesystem Layout + +All paths below are relative to `CODEXAPI_HOME`. + +```text +agents/ + / + meta.json + state.json + AGENTBOOK.md + commands/ + new/ + claimed/ + hosts/ + / + session.json + run.lock + runs/ +locks/ + .tick..lock +bin/ + agent-tick +cron/ + agent.cron +``` + +### `agents//meta.json` + +Purpose: +- Stable identity and configuration. + +Writer: +- Owner host only after agent creation, except for explicit configuration + changes. + +Readers: +- Any host. + +Format: +- JSON object. + +Why it exists: +- Separates mostly-static configuration from rapidly changing state. + +Suggested contents: +- `id`, `name`, `created_at`, `created_by`, `hostname`, `cwd`, `prompt`, + `stop_policy`, `heartbeat_minutes` + +### `agents//state.json` + +Purpose: +- Current snapshot for CLI inspection. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- JSON object rewritten atomically with temp file + rename. + +Why it exists: +- `agent list` and `agent show` should not need to reconstruct state from many + files or logs. + +Suggested contents: +- `status`, `thread_id`, `last_wake_at`, `last_success_at`, `next_wake_at`, + `wake_requested_at`, `unread_message_count`, token totals, `activity`, + `last_error`, `child_ids` + +### `agents//AGENTBOOK.md` + +Purpose: +- Human-readable working memory for the agent, similar to the leadbook. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- Markdown. + +Why it exists: +- Thread ids are not sufficient durable memory. The book is the portable, + inspectable memory surface. + +### `agents//commands/new/` + +Purpose: +- Durable cross-host command spool. + +Writer: +- Any host may create new files here. + +Readers: +- Owner host only for processing, any host for debugging. + +Format: +- One JSON file per command. + +Why it exists: +- It avoids shared append logs and avoids requiring SSH or direct host + reachability. + +Filename rule: + +```text +....json +``` + +Writers must: + +- write to a temp file in the same directory tree +- `fsync` if practical +- rename atomically into `commands/new/` + +Supported V1 commands: + +- `send` +- `wake` +- `pause` +- `resume` +- `cancel` + +### `agents//commands/claimed/` + +Purpose: +- Temporary processing area for commands taken by the owner host. + +Writer: +- Owner host only. + +Readers: +- Mainly owner host; other hosts may inspect for debugging. + +Format: +- Same JSON command files, moved from `new/`. + +Why it exists: +- Claim-by-rename is simple, durable, and avoids double processing. + +After a claimed command is applied, the owner host should record the outcome in +`state.json` or a run record and then remove the command file. The command file +is transport, not long-term audit storage. + +### `agents//hosts//session.json` + +Purpose: +- Host-local runtime data for the owner host. + +Writer: +- Owner host only. + +Readers: +- Mostly owner host. + +Format: +- JSON object. + +Why it exists: +- Keeps the liveliest mutable runtime fields under a host-specific path. + +Suggested contents: +- `thread_id` +- environment snapshot used for execution +- last run metadata that does not need to be duplicated in `state.json` + +### `agents//hosts//run.lock` + +Purpose: +- Non-blocking per-agent run lock. + +Writer: +- Owner host only. + +Readers: +- Owner host only in normal operation. + +Format: +- Permanent lock file used with `flock` or `fcntl`. + +Why it exists: +- Prevents two entry points from resuming the same backend thread at the same + time. + +### `agents//hosts//runs/` + +Purpose: +- Per-wake run records for debugging and recovery. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- One JSON file per wake. + +Why it exists: +- Per-run files are easier to inspect and safer than multi-host append logs. + +Suggested contents: +- start and end times +- reason for wake +- commands consumed +- agent reply text or status payload intended for the CLI +- token deltas +- result summary +- error details if any + +### `bin/agent-tick` + +Purpose: +- Stable wrapper script for cron. + +Writer: +- `codexapi agent install-cron` + +Readers: +- Cron and the user. + +Format: +- Executable shell script. + +Why it exists: +- Cron has a sparse environment. The wrapper pins the interpreter and exports a + safe environment. + +The wrapper should: + +- export the resolved `CODEXAPI_HOME` +- set a safe `PATH` +- invoke the exact Python interpreter or installed `codexapi` path discovered + at install time + +### `cron/agent.cron` + +Purpose: +- Record of the cron line managed for this `CODEXAPI_HOME`. + +Writer: +- `codexapi agent install-cron` + +Readers: +- User and installer commands. + +Format: +- Plain text. + +Why it exists: +- Makes scheduler installation inspectable and testable without reading the + user's entire crontab. + +## Ownership Rules + +The design is intentionally asymmetric. + +- Any host may read any agent in the same `CODEXAPI_HOME`. +- Only the owner host may run the agent. +- Any host may enqueue command files in `commands/new/`. +- Only the owner host may mutate `state.json`, `AGENTBOOK.md`, host runtime + files, and run records. + +Why this matters: + +- It keeps cross-host writes minimal. +- It avoids shared append logs. +- It allows one shared registry across machines without letting an agent wake on + the wrong host. + +## Scheduler + +V1 uses exactly one cron entry per `CODEXAPI_HOME` and per host. + +Cron cadence: + +- every minute + +Cron target: + +- `CODEXAPI_HOME/bin/agent-tick` + +Why one scheduler entry: + +- one place to reason about wake behavior +- no per-agent cron management +- easy recovery after reboot or sleep + +Why cron: + +- available on macOS and Linux +- no root requirement +- simple installation story + +## Tick Lock + +Each host uses a host-specific scheduler lock: + +```text +locks/.tick..lock +``` + +Locking rules: + +- lock acquisition is non-blocking +- if the lock is held, `codexapi agent tick` exits `0` immediately +- missed scheduler invocations are dropped, not queued + +The lock file itself may contain debug text such as pid and start time, but the +authority is the kernel file lock, not file existence. + +Why this matters: + +- a long tick must not cause future ticks to pile up +- crash recovery is automatic because kernel locks are released when the process + dies + +## Per-Agent Run Lock + +Each agent has its own non-blocking run lock under its owner host directory. + +Rules: + +- `tick`, `send`, and any future explicit wake path must all respect this lock +- if the lock is held, the caller must not wait +- if new commands arrive while the agent is running, they stay queued for the + next wake + +Why this matters: + +- one backend process per agent +- no concurrent `resume` on the same thread id + +## Tick Semantics + +`codexapi agent tick` should: + +1. resolve `CODEXAPI_HOME` +2. resolve the current hostname +3. take the host-specific tick lock or exit `0` +4. scan all agents in this home +5. ignore agents whose owner hostname does not match +6. select agents that are due +7. try each due agent with its non-blocking run lock + +An agent is due when all of the following are true: + +- `status` is `ready` or `error` +- owner hostname matches the current hostname +- one of: + - `wake_requested_at` is set + - unread commands/messages exist + - `next_wake_at` is present and in the past + +Heartbeat behavior: + +- missed heartbeat opportunities are dropped +- there is no replay of missed intervals after sleep or reboot +- the next heartbeat is scheduled from the time the current wake finishes, not + from the last planned heartbeat slot + +Why this matters: + +- heartbeats are a chance to check in, not a durable queue +- durable user intent must live in command files, not in hypothetical missed + ticks + +## Command Processing + +Command files are the durable cross-host control plane. + +Suggested command shape: + +```json +{ + "id": "20260306T211500Z.host.pid.abcd", + "created_at": "2026-03-06T21:15:00Z", + "origin_hostname": "workstation-a", + "kind": "send", + "body": "Status?", + "author": "mark" +} +``` + +Processing rules: + +- owner host claims commands by rename from `new/` to `claimed/` +- commands are applied in timestamp order +- `pause` and `cancel` are applied before starting a new backend wake +- `send` contributes to the next prompt and increments unread counts until + consumed +- `wake` means run soon even if no heartbeat is due +- `resume` only changes state when the agent is paused +- after successful application, the owner host records the result in state or a + run record and deletes the claimed file + +Why command files instead of SSH: + +- durable when the owner host is asleep or unreachable +- portable +- fewer assumptions about local network setup + +## Wakes and Backend Process Model + +Each wake is a fresh backend process. + +Rules: + +- do not keep a `codex` process alive between heartbeats +- when a wake starts, resume from `thread_id` if present +- when the wake ends, persist the updated `thread_id` +- if no `thread_id` exists, start a fresh thread + +Why this matters: + +- robust to reboot and crash +- simpler process management +- clearer token accounting per wake + +The backend thread id is useful memory, but not the source of truth. Durable +memory lives in the agent home, especially `state.json`, command files, and +`AGENTBOOK.md`. + +## Environment Handling + +The scheduler environment and the agent execution environment are not assumed to +be the same. + +Each agent should persist enough environment to resume sanely: + +- `cwd` +- `PATH` +- `VIRTUAL_ENV`, if set +- interpreter path used to launch codexapi-related subprocesses when relevant + +Why this matters: + +- the cron-driven scheduler may run from a different venv than the one the user + had active when the agent was created +- repo commands like `python`, `pytest`, and tool wrappers often depend on + `PATH` and `VIRTUAL_ENV` + +V1 should store only the minimum needed to recreate the expected environment. + +## Token Accounting + +V1 should not pretend to know dollar cost. + +Track: + +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` + +Token totals belong in `state.json` so `agent list` can show them cheaply. + +Why this matters: + +- heartbeat-heavy agents can become unexpectedly expensive in quota terms +- users need a simple proxy for long-running agent cost + +`avg_tokens_per_hour` is a lifetime running average in V1. More detailed recent +windows can be added later if needed. + +## CLI Contract + +V1 CLI surface: + +- `codexapi agent start` +- `codexapi agent list` +- `codexapi agent read` +- `codexapi agent show` +- `codexapi agent send` +- `codexapi agent wake` +- `codexapi agent pause` +- `codexapi agent resume` +- `codexapi agent cancel` +- `codexapi agent tick` +- `codexapi agent install-cron` + +Expected behavior: + +- `start` creates the agent directory, meta/state files, and host runtime files +- `list` reads only this `CODEXAPI_HOME` +- `read` shows recent user-visible communication derived from state and run + records +- `show` reads one agent's current snapshot and recent run history +- `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files +- `tick` processes due agents for the current hostname only +- `install-cron` installs exactly one scheduler entry for this home on this host + +Why command-oriented CLI actions: + +- one path for local and cross-host control +- durable intent +- simpler concurrency model + +## Failure Recovery + +V1 should explicitly recover from common failure modes. + +### Reboot or Sleep + +- missed cron minutes are ignored +- the next cron minute runs `agent tick` +- due agents are selected from current state, not from queued heartbeat ticks + +### Tick Crash + +- kernel lock is released when the tick process dies +- next cron minute may run normally + +### Wake Crash + +- per-agent run lock is released when the process dies +- next tick sees the agent is not actually locked +- if `state.json` still says `running`, reconcile it to `error` or `ready` + before proceeding + +### Owner Host Unavailable + +- other hosts may still inspect the agent and enqueue commands +- commands remain durable until the owner host comes back + +## Testing Strategy + +The main automated testing tool is a temporary `CODEXAPI_HOME`. + +Why this is the right testing seam: + +- it isolates tests from live agents +- it allows end-to-end command and tick tests without real cron +- it matches the real control-plane boundary + +### Test Rules + +- every integration test sets `CODEXAPI_HOME` to a temp directory +- tests call CLI commands or internal functions directly +- tests run `codexapi agent tick` directly instead of invoking cron +- tests must never depend on the default `~/.codexapi` + +### Test Layers + +Unit tests: + +- due-agent selection +- heartbeat scheduling +- token accounting +- path resolution +- command parsing +- state transition logic + +Filesystem integration tests: + +- create an agent +- enqueue command files +- run `tick` +- verify command consumption, state updates, and next wake times +- verify that different `CODEXAPI_HOME` roots are fully isolated + +Backend-stub integration tests: + +- replace real backend execution with a fake runner +- return canned outputs and thread ids +- verify prompt construction, session resume, and token accounting + +Scheduler tests: + +- verify wrapper script generation +- verify cron line rendering +- verify that two different `CODEXAPI_HOME` roots on one host produce separate + scheduler artifacts +- do not touch a real user crontab in normal automated tests + +Cross-host tests: + +- fake different hostnames +- verify that only the owner hostname wakes an agent +- verify that non-owner hosts can still enqueue commands + +Locking tests: + +- simulate tick lock contention and assert fast `0` exit +- simulate per-agent run lock contention and assert no second wake starts + +## Invariants + +These are the rules the implementation should preserve. + +- `CODEXAPI_HOME` is a complete isolated control plane. +- One cron installation belongs to one home on one host. +- Only the owner hostname may wake an agent. +- Heartbeat opportunities are lossy. +- Commands are durable. +- No caller waits on the tick lock or per-agent run lock. +- There is never more than one live backend process per agent. +- The backend thread id is useful state, not the source of truth. +- Cross-host writes use one-file command spooling, not shared append logs. +- Agent state shown in the CLI comes from `state.json`, not from expensive live + reconstruction. + +## Why This Design Is Small Enough + +This design deliberately avoids many attractive additions. + +- It does not require a daemon. +- It does not require host-to-host RPC. +- It does not require richer distributed locking than the filesystem already + provides. +- It does not require a database. + +What remains is the minimum necessary structure for a durable, inspectable, +multi-day agent system: + +- one state root +- one scheduler entry +- one agent directory per agent +- one command spool per agent +- one host owner per agent +- one run at a time + +That is a good V1 shape. From 05ea545c37732560cb20c0030aa9125e16e2ff1a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 01:37:38 +0100 Subject: [PATCH 02/31] Add durable agent control-plane foundation --- src/codexapi/agent.py | 35 +- src/codexapi/agents.py | 864 +++++++++++++++++++++++++++++++++++++++++ src/codexapi/cli.py | 178 +++++++++ tests/test_agents.py | 131 +++++++ 4 files changed, 1202 insertions(+), 6 deletions(-) create mode 100644 src/codexapi/agents.py create mode 100644 tests/test_agents.py diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 1e2526c..6ab2f11 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -31,6 +31,7 @@ def agent( flags=None, include_thinking=False, backend=None, + env=None, ): """Run a single agent turn and return only the agent's message. @@ -41,12 +42,13 @@ def agent( flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend + prompt, cwd, None, yolo, flags, include_thinking, backend, env ) return message @@ -78,6 +80,7 @@ def __init__( welfare=False, include_thinking=False, backend=None, + env=None, ): """Create a new session wrapper. @@ -90,6 +93,7 @@ def __init__( and raise WelfareStop if the agent outputs MAKE IT STOP. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. """ self.cwd = cwd self._yolo = yolo @@ -98,6 +102,7 @@ def __init__( self._include_thinking = include_thinking self.thread_id = thread_id self._backend = backend + self._env = env def __call__(self, prompt): """Send a prompt to the agent backend and return the message.""" @@ -111,6 +116,7 @@ def __call__(self, prompt): self._flags, self._include_thinking, self._backend, + self._env, ) if thread_id: self.thread_id = thread_id @@ -119,14 +125,14 @@ def __call__(self, prompt): return message -def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend): +def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): backend = _resolve_backend(backend) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking) - return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking) + return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -155,6 +161,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -166,7 +173,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): return _parse_jsonl(result.stdout, include_thinking) -def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" command = [ _CURSOR_BIN, @@ -189,6 +196,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -285,3 +293,18 @@ def _parse_cursor_json(output, include_thinking): if not isinstance(session_id, str): session_id = None return result, session_id + + +def _merged_env(env): + """Return subprocess env overlaying the current process env.""" + if env is None: + return None + if not isinstance(env, dict): + raise TypeError("env must be a dict or None") + merged = os.environ.copy() + for key, value in env.items(): + if value is None: + merged.pop(str(key), None) + else: + merged[str(key)] = str(value) + return merged diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py new file mode 100644 index 0000000..4092958 --- /dev/null +++ b/src/codexapi/agents.py @@ -0,0 +1,864 @@ +"""Durable long-running agent control plane.""" + +import json +import os +import random +import socket +import string +import tempfile +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import fcntl + +from .agent import Agent +from .pushover import Pushover + +_DEFAULT_HOME = "~/.codexapi" +_AGENTBOOK_TEMPLATE = """# Agentbook + +Use this file as the durable working memory for the agent. +Append dated notes as work progresses. +Keep entries short and concrete. +""" +_AGENT_PROMPT = ( + "You are a long-term codexapi agent. You are being woken up to make progress " + "on an ongoing job. Be independent and practical. Manage work and follow " + "through. Use codexapi task or codexapi science when you want a separate " + "coding worker. If you need the user's attention, put a short message in the " + "reply field. If something is urgent and should send Pushover, put it in the " + "notify field. Respond with JSON only." +) +_AGENT_JSON = ( + "Respond with JSON only (no markdown/backticks/extra text).\n" + "Return a single JSON object with keys:\n" + " status: string (one line)\n" + " continue: boolean\n" + " reply: string (optional)\n" + " notify: string (optional)\n" +) +_COMMAND_KINDS = {"send", "wake", "pause", "resume", "cancel"} +_STOP_POLICIES = {"until_done", "until_stopped"} +_TERMINAL_STATES = {"done", "canceled"} +_ACTIVE_STATES = {"ready", "error", "running", "paused"} + + +def codexapi_home(): + """Return the resolved codexapi home path.""" + value = os.environ.get("CODEXAPI_HOME", _DEFAULT_HOME) + return Path(value).expanduser().resolve() + + +def current_hostname(): + """Return the current hostname.""" + name = socket.gethostname().strip() + return name or "unknown-host" + + +def utc_now(): + """Return the current UTC time.""" + return datetime.now(timezone.utc) + + +def format_utc(value): + """Format a UTC datetime as an ISO string with Z.""" + if value is None: + return "" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + value = value.astimezone(timezone.utc).replace(microsecond=0) + return value.isoformat().replace("+00:00", "Z") + + +def parse_utc(value): + """Parse a UTC timestamp written by this module.""" + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def start_agent( + prompt, + cwd=None, + name=None, + created_by=None, + stop_policy="until_done", + heartbeat_minutes=5, + backend=None, + yolo=True, + flags=None, + home=None, + hostname=None, + now=None, +): + """Create a durable agent and return its current snapshot.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + if stop_policy not in _STOP_POLICIES: + raise ValueError("stop_policy must be until_done or until_stopped") + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + _ensure_home(home) + + agent_id = uuid.uuid4().hex + agent_dir = _agent_dir(home, agent_id) + commands_new = agent_dir / "commands" / "new" + commands_claimed = agent_dir / "commands" / "claimed" + host_dir = agent_dir / "hosts" / host + runs_dir = host_dir / "runs" + + commands_new.mkdir(parents=True, exist_ok=False) + commands_claimed.mkdir(parents=True, exist_ok=False) + runs_dir.mkdir(parents=True, exist_ok=False) + + if created_by is None: + created_by = os.environ.get("USER") or "user" + cwd = _resolve_cwd(cwd) + session = { + "thread_id": "", + "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), + "yolo": bool(yolo), + "flags": flags or "", + "cwd": cwd, + "env": _capture_env(), + "pending_messages": [], + } + agent_name = _choose_name(home, prompt, name) + meta = { + "id": agent_id, + "name": agent_name, + "created_at": format_utc(now), + "created_by": str(created_by), + "hostname": host, + "cwd": cwd, + "prompt": prompt.strip(), + "stop_policy": stop_policy, + "heartbeat_minutes": int(heartbeat_minutes), + } + state = { + "id": agent_id, + "name": agent_name, + "hostname": host, + "status": "ready", + "thread_id": "", + "last_wake_at": "", + "last_success_at": "", + "next_wake_at": format_utc(now), + "wake_requested_at": format_utc(now), + "unread_message_count": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "avg_tokens_per_hour": 0.0, + "child_ids": [], + "last_error": "", + "activity": "Created", + "reply": "", + } + + _write_json(agent_dir / "meta.json", meta) + _write_json(agent_dir / "state.json", state) + _write_json(host_dir / "session.json", session) + _write_text(agent_dir / "AGENTBOOK.md", _AGENTBOOK_TEMPLATE) + return _snapshot(agent_dir) + + +def list_agents(home=None): + """Return all agents in this CODEXAPI_HOME.""" + home = _resolve_home(home) + root = home / "agents" + if not root.exists(): + return [] + agents = [] + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + agents.append(_snapshot(agent_dir)) + except FileNotFoundError: + continue + agents.sort(key=lambda item: item["created_at"], reverse=True) + return agents + + +def show_agent(agent_ref, home=None): + """Return a full agent snapshot.""" + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir) + snapshot["meta"] = _read_json(agent_dir / "meta.json") + snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["session"] = _read_session(agent_dir) + snapshot["recent_runs"] = _recent_runs(agent_dir, 5) + return snapshot + + +def read_agent(agent_ref, limit=10, home=None): + """Return recent user-visible communication for an agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + items = [] + for run in _recent_runs(agent_dir, limit): + reply = run.get("reply") or "" + if reply: + items.append( + { + "kind": "agent", + "timestamp": run.get("ended_at") or run.get("started_at") or "", + "text": reply, + } + ) + for pending in session.get("pending_messages") or []: + text = pending.get("text") or "" + if text: + items.append( + { + "kind": "pending", + "timestamp": pending.get("created_at") or "", + "text": text, + } + ) + items.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "items": items[:limit], + } + + +def send_agent(agent_ref, message, author=None, home=None, hostname=None, now=None): + """Queue a message for an agent.""" + if not isinstance(message, str) or not message.strip(): + raise ValueError("message must be a non-empty string") + return _queue_command( + agent_ref, + "send", + message.strip(), + author, + home, + hostname, + now, + ) + + +def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=None): + """Queue a control command for an agent.""" + if kind not in _COMMAND_KINDS - {"send"}: + raise ValueError(f"Unsupported control command: {kind}") + return _queue_command(agent_ref, kind, "", author, home, hostname, now) + + +def tick(home=None, hostname=None, now=None, runner=None): + """Process due agents for the current host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + _ensure_home(home) + tick_lock = _tick_lock_path(home, host) + with _try_lock(tick_lock) as handle: + if handle is None: + return {"ran": False, "hostname": host, "processed": 0, "woken": 0} + _write_lock_info(handle, host, now) + processed = 0 + woken = 0 + for agent in list_agents(home): + if agent["hostname"] != host: + continue + outcome = _tick_agent(_agent_dir(home, agent["id"]), now, runner) + if outcome["processed"]: + processed += 1 + if outcome["woken"]: + woken += 1 + return {"ran": True, "hostname": host, "processed": processed, "woken": woken} + + +def resolve_agent_dir(agent_ref, home=None): + """Resolve an agent by id, unique id prefix, or name.""" + if not isinstance(agent_ref, str) or not agent_ref.strip(): + raise ValueError("agent reference is required") + home = _resolve_home(home) + ref = agent_ref.strip() + matches = [] + for item in list_agents(home): + if item["id"] == ref: + return _agent_dir(home, item["id"]) + if item["name"] == ref: + matches.append(item["id"]) + continue + if item["id"].startswith(ref): + matches.append(item["id"]) + matches = sorted(set(matches)) + if not matches: + raise ValueError(f"Unknown agent: {ref}") + if len(matches) > 1: + raise ValueError(f"Ambiguous agent reference: {ref}") + return _agent_dir(home, matches[0]) + + +def _tick_agent(agent_dir, now, runner): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + host_dir = agent_dir / "hosts" / meta["hostname"] + session_path = host_dir / "session.json" + session = _read_json(session_path) + run_lock_path = host_dir / "run.lock" + + with _try_lock(run_lock_path) as handle: + if handle is None: + return {"processed": False, "woken": False} + _write_lock_info(handle, meta["hostname"], now) + changed = False + if state.get("status") == "running": + state["status"] = "error" + state["last_error"] = "Previous wake did not exit cleanly." + state["activity"] = state["last_error"] + changed = True + commands = _claim_commands(agent_dir) + applied = _apply_commands(meta, state, session, commands, now) + if applied: + changed = True + if changed: + _sync_state_from_session(state, session) + _write_json(session_path, session) + _write_json(agent_dir / "state.json", state) + if state.get("status") not in ("ready", "error"): + return {"processed": bool(commands), "woken": False} + if not _is_due(state, now): + return {"processed": bool(commands), "woken": False} + _wake_agent(agent_dir, meta, state, session, now, commands, runner) + return {"processed": True, "woken": True} + + +def _wake_agent(agent_dir, meta, state, session, now, commands, runner): + prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) + state["status"] = "running" + state["last_wake_at"] = format_utc(now) + state["wake_requested_at"] = "" + state["activity"] = "Running" + _sync_state_from_session(state, session) + _write_json(agent_dir / "state.json", state) + + run = { + "id": _run_id(now), + "started_at": format_utc(now), + "ended_at": "", + "wake_reason": _wake_reason(state, commands), + "commands": [command["kind"] for command in commands], + "status": "", + "reply": "", + "notify": "", + "error": "", + "continue": True, + } + try: + outcome = _run_agent_turn(meta, session, prompt, runner) + response = _parse_agent_response(outcome["message"]) + ended = utc_now() + session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" + session["pending_messages"] = [] + state["reply"] = response["reply"] + state["last_success_at"] = format_utc(ended) + state["last_error"] = "" + state["thread_id"] = session["thread_id"] + state["wake_requested_at"] = "" + state["activity"] = response["status"] + if response["continue"]: + state["status"] = "ready" + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + else: + state["status"] = "done" + state["next_wake_at"] = "" + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["status"] = response["status"] + run["reply"] = response["reply"] + run["notify"] = response["notify"] + run["continue"] = bool(response["continue"]) + _write_run(agent_dir, meta["hostname"], run) + if response["notify"]: + title = f"Agent: {meta['name']}" + Pushover().send(title, response["notify"]) + except Exception as exc: + ended = utc_now() + state["status"] = "error" + state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__ + state["activity"] = state["last_error"] + state["wake_requested_at"] = "" + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["error"] = state["last_error"] + _write_run(agent_dir, meta["hostname"], run) + + +def _run_agent_turn(meta, session, prompt, runner=None): + if runner is not None: + return runner(meta, session, prompt) + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=session.get("env") or None, + ) + message = worker(prompt) + return {"message": message, "thread_id": worker.thread_id or ""} + + +def _parse_agent_response(output): + text = _strip_fence(str(output or "").strip()) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON response: {exc}") from None + if not isinstance(payload, dict): + raise ValueError("Agent response must be a JSON object.") + status = payload.get("status") + cont = payload.get("continue") + reply = payload.get("reply") + notify = payload.get("notify") + if not isinstance(status, str) or not status.strip(): + raise ValueError("Agent response missing string 'status'.") + if not isinstance(cont, bool): + raise ValueError("Agent response missing boolean 'continue'.") + if reply is None: + reply = "" + if notify is None: + notify = "" + if not isinstance(reply, str): + raise ValueError("Agent response missing string 'reply'.") + if not isinstance(notify, str): + raise ValueError("Agent response missing string 'notify'.") + return { + "status": _single_line(status), + "continue": cont, + "reply": reply.strip(), + "notify": notify.strip(), + } + + +def _build_wake_prompt(meta, state, session, now, commands, agent_dir): + messages = session.get("pending_messages") or [] + lines = [ + _AGENT_PROMPT, + "", + f"Current UTC time: {format_utc(now)}", + f"Agent name: {meta['name']}", + f"Stop policy: {meta['stop_policy']}", + f"Heartbeat minutes: {meta['heartbeat_minutes']}", + "", + "Original instructions:", + meta["prompt"], + "", + f"Working directory: {meta['cwd']}", + f"Agentbook path: {agent_dir / 'AGENTBOOK.md'}", + "Append a dated note to the agentbook before you respond.", + ] + book = _read_text(agent_dir / "AGENTBOOK.md") + if book.strip(): + lines.extend(["", "Agentbook (latest):", _snippet(book, 3000)]) + if messages: + lines.extend(["", "Queued user messages:"]) + for message in messages: + created_at = message.get("created_at") or "" + author = message.get("author") or "user" + text = message.get("text") or "" + lines.append(f"- [{created_at}] {author}: {text}") + else: + lines.extend(["", "Queued user messages: none."]) + if commands: + lines.extend(["", "Wake triggers:"]) + for command in commands: + lines.append(f"- {command['kind']}") + last_reply = state.get("reply") or "" + if last_reply: + lines.extend(["", "Your last visible reply:", _snippet(last_reply, 1200)]) + lines.extend(["", _AGENT_JSON]) + return "\n".join(lines).strip() + + +def _claim_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + claimed_dir = agent_dir / "commands" / "claimed" + commands = [] + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file(): + continue + target = claimed_dir / path.name + try: + path.rename(target) + except FileNotFoundError: + continue + command = _read_json(target) + command["_path"] = str(target) + commands.append(command) + return commands + + +def _apply_commands(meta, state, session, commands, now): + changed = False + pending = list(session.get("pending_messages") or []) + for command in commands: + kind = command.get("kind") + if kind == "send": + pending.append( + { + "id": command.get("id") or "", + "created_at": command.get("created_at") or format_utc(now), + "author": command.get("author") or "user", + "origin_hostname": command.get("origin_hostname") or "", + "text": command.get("body") or "", + } + ) + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "wake": + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "pause": + state["status"] = "paused" + state["activity"] = "Paused" + changed = True + elif kind == "resume": + if state.get("status") == "paused": + state["status"] = "ready" + state["wake_requested_at"] = format_utc(now) + state["activity"] = "Resumed" + changed = True + elif kind == "cancel": + state["status"] = "canceled" + state["activity"] = "Canceled" + state["wake_requested_at"] = "" + state["next_wake_at"] = "" + changed = True + session["pending_messages"] = pending + _sync_state_from_session(state, session) + for command in commands: + path = command.get("_path") + if path: + try: + os.unlink(path) + except FileNotFoundError: + pass + return changed + + +def _is_due(state, now): + status = state.get("status") + if status not in ("ready", "error"): + return False + if state.get("wake_requested_at"): + return True + if status == "ready" and int(state.get("unread_message_count") or 0) > 0: + return True + next_wake = parse_utc(state.get("next_wake_at")) + if next_wake and next_wake <= now: + return True + return False + + +def _write_run(agent_dir, hostname, payload): + runs_dir = agent_dir / "hosts" / hostname / "runs" + filename = f"{payload['id']}.json" + _write_json(runs_dir / filename, payload) + + +def _recent_runs(agent_dir, limit): + meta = _read_json(agent_dir / "meta.json") + runs_dir = agent_dir / "hosts" / meta["hostname"] / "runs" + if not runs_dir.exists(): + return [] + runs = [] + for path in sorted(runs_dir.iterdir(), key=lambda item: item.name, reverse=True): + if not path.is_file() or path.suffix != ".json": + continue + runs.append(_read_json(path)) + if len(runs) >= limit: + break + return runs + + +def _queue_command(agent_ref, kind, body, author, home, hostname, now): + if kind not in _COMMAND_KINDS: + raise ValueError(f"Unsupported command: {kind}") + agent_dir = resolve_agent_dir(agent_ref, home) + now = now or utc_now() + host = hostname or current_hostname() + author = author or os.environ.get("USER") or "user" + payload = { + "id": _command_id(now, host), + "created_at": format_utc(now), + "origin_hostname": host, + "kind": kind, + "body": body, + "author": str(author), + } + new_dir = agent_dir / "commands" / "new" + _atomic_create_json(new_dir, f"{payload['id']}.json", payload) + return payload + + +def _snapshot(agent_dir): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + return { + "id": meta["id"], + "name": meta["name"], + "created_at": meta["created_at"], + "created_by": meta["created_by"], + "hostname": meta["hostname"], + "cwd": meta["cwd"], + "stop_policy": meta["stop_policy"], + "heartbeat_minutes": meta["heartbeat_minutes"], + "status": state.get("status") or "", + "thread_id": state.get("thread_id") or "", + "last_wake_at": state.get("last_wake_at") or "", + "last_success_at": state.get("last_success_at") or "", + "next_wake_at": state.get("next_wake_at") or "", + "wake_requested_at": state.get("wake_requested_at") or "", + "unread_message_count": int(state.get("unread_message_count") or 0), + "input_tokens": int(state.get("input_tokens") or 0), + "output_tokens": int(state.get("output_tokens") or 0), + "total_tokens": int(state.get("total_tokens") or 0), + "avg_tokens_per_hour": float(state.get("avg_tokens_per_hour") or 0.0), + "last_error": state.get("last_error") or "", + "activity": state.get("activity") or "", + "reply": state.get("reply") or "", + } + + +def _choose_name(home, prompt, requested): + base = _slugify(requested or prompt) + if not base: + base = "agent" + existing = {item["name"] for item in list_agents(home)} + if base not in existing: + return base + index = 2 + while True: + candidate = f"{base}-{index}" + if candidate not in existing: + return candidate + index += 1 + + +def _slugify(text): + if not isinstance(text, str): + return "" + cleaned = [] + for char in text.lower(): + if char.isalnum(): + cleaned.append(char) + continue + cleaned.append("-") + slug = "".join(cleaned) + while "--" in slug: + slug = slug.replace("--", "-") + slug = slug.strip("-") + if not slug: + return "" + parts = [part for part in slug.split("-") if part] + if not parts: + return "" + return "-".join(parts[:6]) + + +def _resolve_cwd(cwd): + target = cwd or os.getcwd() + return str(Path(target).expanduser().resolve()) + + +def _capture_env(): + env = {} + for key in ("PATH", "VIRTUAL_ENV"): + value = os.environ.get(key) + if value: + env[key] = value + return env + + +def _resolve_home(home): + if home is None: + return codexapi_home() + return Path(home).expanduser().resolve() + + +def _ensure_home(home): + (home / "agents").mkdir(parents=True, exist_ok=True) + (home / "locks").mkdir(parents=True, exist_ok=True) + (home / "bin").mkdir(parents=True, exist_ok=True) + (home / "cron").mkdir(parents=True, exist_ok=True) + + +def _agent_dir(home, agent_id): + return home / "agents" / agent_id + + +def _tick_lock_path(home, hostname): + return home / "locks" / f".tick.{hostname}.lock" + + +@contextmanager +def _try_lock(path): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a+", encoding="utf-8") as handle: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield None + return + yield handle + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _write_lock_info(handle, hostname, now): + handle.seek(0) + handle.truncate() + handle.write(json.dumps({"pid": os.getpid(), "hostname": hostname, "started_at": format_utc(now)})) + handle.flush() + + +def _read_session(agent_dir): + meta = _read_json(agent_dir / "meta.json") + return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") + + +def _sync_state_from_session(state, session): + pending = session.get("pending_messages") or [] + state["unread_message_count"] = len(pending) + state["thread_id"] = session.get("thread_id") or "" + + +def _command_id(now, hostname): + stamp = now.strftime("%Y%m%dT%H%M%SZ") + rand = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) + return f"{stamp}.{hostname}.{os.getpid()}.{rand}" + + +def _run_id(now): + return f"{now.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + + +def _atomic_create_json(directory, filename, payload): + directory.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, directory / filename) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_text(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _read_json(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def _read_text(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + except FileNotFoundError: + return "" + + +def _snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return text[: limit - 3] + "..." + + +def _strip_fence(text): + if not text.startswith("```"): + return text + lines = text.splitlines() + if len(lines) < 3: + return text + if lines[-1].strip() != "```": + return text + return "\n".join(lines[1:-1]).strip() + + +def _single_line(text): + if not text: + return "" + return " ".join(str(text).replace("\r", " ").split()) + + +def _wake_reason(state, commands): + reasons = [] + if state.get("wake_requested_at"): + reasons.append("wake_requested") + if int(state.get("unread_message_count") or 0) > 0: + reasons.append("messages") + if commands: + reasons.append("commands") + if not reasons: + reasons.append("heartbeat") + return ",".join(sorted(set(reasons))) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f0ac111..efc9a38 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -13,6 +13,15 @@ from pathlib import Path from .agent import Agent, agent +from .agents import ( + control_agent, + list_agents as list_managed_agents, + read_agent as read_managed_agent, + send_agent, + show_agent as show_managed_agent, + start_agent as start_managed_agent, + tick as tick_managed_agents, +) from .foreach import foreach from .ralph import Ralph, cancel_ralph_loop from .science import Science @@ -131,6 +140,35 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) +def _print_managed_agent_list(items): + if not items: + print("No agents.") + return + print("ID STAT HOST UNREAD TOKENS NAME") + for item in items: + ident = item["id"][:8] + status = _truncate_head(item["status"] or "-", 8) + host = _truncate_head(item["hostname"] or "-", 15) + unread = str(item["unread_message_count"]) + tokens = _format_token_total(item["total_tokens"]) + name = item["name"] + print(f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {name}") + + +def _print_managed_agent_read(result): + print(f"{result['name']} [{result['status']}]") + items = result.get("items") or [] + if not items: + print("No messages.") + return + for item in items: + stamp = item.get("timestamp") or "-" + kind = item.get("kind") or "item" + print(f"[{stamp}] {kind}:") + print(item.get("text") or "") + print() + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -1140,6 +1178,101 @@ def main(argv=None): help="Print the current thread id to stderr after running.", ) + agent_parser = subparsers.add_parser( + "agent", + help="Manage durable long-running agents.", + ) + agent_subparsers = agent_parser.add_subparsers(dest="agent_command") + + agent_start = agent_subparsers.add_parser( + "start", + help="Create a durable agent.", + ) + agent_start.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + agent_start.add_argument("--cwd", help="Working directory for the agent.") + agent_start.add_argument("--name", help="Optional agent name.") + agent_start.add_argument( + "--created-by", + help="Creator label (defaults to $USER).", + ) + agent_start.add_argument( + "--stop-policy", + default="until_done", + choices=("until_done", "until_stopped"), + help="Whether the agent stops itself when done or runs until stopped.", + ) + agent_start.add_argument( + "--heartbeat-minutes", + type=int, + default=5, + help="Heartbeat interval in minutes (default: 5).", + ) + agent_start.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) + agent_start.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Disable --yolo (Codex uses --full-auto).", + ) + agent_start.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the backend.", + ) + + agent_subparsers.add_parser( + "list", + help="List durable agents in this CODEXAPI_HOME.", + ) + + agent_show = agent_subparsers.add_parser( + "show", + help="Show one durable agent.", + ) + agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_read = agent_subparsers.add_parser( + "read", + help="Read recent visible communication for one agent.", + ) + agent_read.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_read.add_argument( + "--limit", + type=int, + default=10, + help="Maximum number of items to show (default: 10).", + ) + + agent_send = agent_subparsers.add_parser( + "send", + help="Queue a message for an agent.", + ) + agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_send.add_argument("message", help="Message to queue.") + agent_send.add_argument("--author", help="Author label for the message.") + + for subcommand, help_text in ( + ("wake", "Request an extra wake for an agent."), + ("pause", "Pause an agent."), + ("resume", "Resume a paused agent."), + ("cancel", "Cancel an agent."), + ): + subparser = agent_subparsers.add_parser(subcommand, help=help_text) + subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + subparser.add_argument("--author", help="Author label for the command.") + + agent_subparsers.add_parser( + "tick", + help="Process due agents for the current host.", + ) + task_parser = subparsers.add_parser( "task", help="Run a task with verification retries.", @@ -1435,6 +1568,51 @@ def main(argv=None): if args.command is None: parser.print_help() raise SystemExit(2) + if args.command == "agent": + if args.agent_command is None: + agent_parser.print_help() + raise SystemExit(2) + if args.agent_command == "start": + prompt = _read_prompt(args.prompt) + result = start_managed_agent( + prompt, + args.cwd, + args.name, + args.created_by, + args.stop_policy, + args.heartbeat_minutes, + args.backend, + args.yolo, + args.flags, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "list": + _print_managed_agent_list(list_managed_agents()) + return + if args.agent_command == "show": + print(json.dumps(show_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + return + if args.agent_command == "read": + if args.limit < 1: + raise SystemExit("--limit must be >= 1.") + _print_managed_agent_read(read_managed_agent(args.agent_ref, args.limit)) + return + if args.agent_command == "send": + result = send_agent(args.agent_ref, args.message, args.author) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("wake", "pause", "resume", "cancel"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "tick": + print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..f96c2d1 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,131 @@ +import json +import os +import sys +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.agents import ( + _tick_lock_path, + _try_lock, + control_agent, + read_agent, + send_agent, + show_agent, + start_agent, + tick, +) + + +@contextmanager +def _temp_home(): + with tempfile.TemporaryDirectory() as tmpdir: + with patch.dict(os.environ, {"CODEXAPI_HOME": tmpdir, "USER": "tester"}, clear=False): + yield Path(tmpdir) + + +class AgentsTests(unittest.TestCase): + def test_homes_are_isolated(self): + with _temp_home() as home_a: + first = start_agent("Monitor the build queue.", hostname="host-a") + self.assertEqual(first["name"], "monitor-the-build-queue") + agents_a = show_agent(first["id"]) + self.assertEqual(agents_a["meta"]["hostname"], "host-a") + with _temp_home() as home_b: + with self.assertRaises(ValueError): + show_agent(first["id"]) + second = start_agent("Watch CI failures.", hostname="host-b") + self.assertNotEqual(first["id"], second["id"]) + + def test_cross_host_message_waits_for_owner_tick(self): + prompts = [] + + def fake_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Replied", + "continue": False, + "reply": "I saw your message.", + } + ), + "thread_id": "thread-abc", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + other_host = tick(hostname="host-b", runner=fake_runner) + self.assertTrue(other_host["ran"]) + self.assertEqual(other_host["processed"], 0) + self.assertEqual(other_host["woken"], 0) + + owner = tick(hostname="host-a", runner=fake_runner) + self.assertTrue(owner["ran"]) + self.assertEqual(owner["woken"], 1) + self.assertEqual(len(prompts), 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["thread_id"], "thread-abc") + self.assertEqual(shown["state"]["reply"], "I saw your message.") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + conversation = read_agent(agent["id"]) + self.assertEqual(conversation["items"][0]["kind"], "agent") + self.assertEqual(conversation["items"][0]["text"], "I saw your message.") + + def test_pause_then_resume(self): + calls = [] + + def fake_runner(meta, session, prompt): + calls.append(prompt) + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-xyz", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-b") + paused = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(paused["processed"], 1) + self.assertEqual(paused["woken"], 0) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "paused") + self.assertEqual(calls, []) + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(resumed["woken"], 1) + self.assertEqual(len(calls), 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + + def test_tick_lock_is_non_blocking(self): + with _temp_home() as home: + start_agent("Do the thing.", hostname="host-a") + lock_path = _tick_lock_path(home, "host-a") + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + result = tick(hostname="host-a") + self.assertFalse(result["ran"]) + self.assertEqual(result["processed"], 0) + self.assertEqual(result["woken"], 0) + + +if __name__ == "__main__": + unittest.main() From 765017b2e2320347c27703f966d95016cfeeb7d5 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:14:19 +0100 Subject: [PATCH 03/31] Add cron installer for durable agents --- src/codexapi/agents.py | 107 +++++++++++++++++++++++++++++++++++++++++ src/codexapi/cli.py | 8 +++ tests/test_agents.py | 52 ++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 4092958..066e683 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,12 +3,16 @@ import json import os import random +import shlex import socket import string +import subprocess +import sys import tempfile import uuid from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from hashlib import sha1 from pathlib import Path import fcntl @@ -289,6 +293,30 @@ def tick(home=None, hostname=None, now=None, runner=None): return {"ran": True, "hostname": host, "processed": processed, "woken": woken} +def install_cron(home=None, hostname=None, python_executable=None, path_value=None): + """Install or update the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + wrapper = write_tick_wrapper(home, python_executable, path_value) + cron_line = render_cron_line(home, host) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _upsert_cron_line(existing, cron_line, tag) + if changed: + _write_crontab(updated) + _write_text(home / "cron" / "agent.cron", cron_line + "\n") + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "cron_line": cron_line, + "changed": changed, + } + + def resolve_agent_dir(agent_ref, home=None): """Resolve an agent by id, unique id prefix, or name.""" if not isinstance(agent_ref, str) or not agent_ref.strip(): @@ -312,6 +340,32 @@ def resolve_agent_dir(agent_ref, home=None): return _agent_dir(home, matches[0]) +def write_tick_wrapper(home=None, python_executable=None, path_value=None): + """Write the cron wrapper script and return its path.""" + home = _resolve_home(home) + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + wrapper = home / "bin" / "agent-tick" + lines = [ + "#!/bin/bash", + f"export CODEXAPI_HOME={shlex.quote(str(home))}", + f"export PATH={shlex.quote(path_value)}", + f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", + ] + _write_text(wrapper, "\n".join(lines) + "\n") + wrapper.chmod(0o755) + return wrapper + + +def render_cron_line(home=None, hostname=None): + """Return the cron line for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + wrapper = home / "bin" / "agent-tick" + return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }" + + def _tick_agent(agent_dir, now, runner): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") @@ -862,3 +916,56 @@ def _wake_reason(state, commands): if not reasons: reasons.append("heartbeat") return ",".join(sorted(set(reasons))) + + +def _cron_tag(home, hostname): + key = sha1(str(home).encode("utf-8")).hexdigest()[:12] + return f"codexapi-agent::{hostname}::{key}" + + +def _upsert_cron_line(existing, line, tag): + lines = [] + found = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + if not found: + lines.append(line) + found = True + continue + lines.append(raw) + if not found: + lines.append(line) + found = True + changed = True + else: + changed = "\n".join(lines).strip() != str(existing or "").strip() + text = "\n".join(item for item in lines if item is not None) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + +def _read_crontab(): + result = subprocess.run( + ["crontab", "-l"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout + stderr = (result.stderr or "").strip().lower() + stdout = (result.stdout or "").strip().lower() + if "no crontab" in stderr or "no crontab" in stdout: + return "" + raise RuntimeError(result.stderr.strip() or "crontab -l failed") + + +def _write_crontab(text): + result = subprocess.run( + ["crontab", "-"], + input=text, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "crontab install failed") diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index efc9a38..8bb8a9a 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -15,6 +15,7 @@ from .agent import Agent, agent from .agents import ( control_agent, + install_cron as install_agent_cron, list_agents as list_managed_agents, read_agent as read_managed_agent, send_agent, @@ -1272,6 +1273,10 @@ def main(argv=None): "tick", help="Process due agents for the current host.", ) + agent_subparsers.add_parser( + "install-cron", + help="Install or update the cron entry for this CODEXAPI_HOME.", + ) task_parser = subparsers.add_parser( "task", @@ -1613,6 +1618,9 @@ def main(argv=None): if args.agent_command == "tick": print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) return + if args.agent_command == "install-cron": + print(json.dumps(install_agent_cron(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index f96c2d1..15ebc34 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -12,12 +12,16 @@ from codexapi.agents import ( _tick_lock_path, _try_lock, + _upsert_cron_line, control_agent, + install_cron, read_agent, + render_cron_line, send_agent, show_agent, start_agent, tick, + write_tick_wrapper, ) @@ -126,6 +130,54 @@ def test_tick_lock_is_non_blocking(self): self.assertEqual(result["processed"], 0) self.assertEqual(result["woken"], 0) + def test_write_tick_wrapper_pins_home_and_python(self): + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + text = wrapper.read_text(encoding="utf-8") + self.assertIn("export CODEXAPI_HOME=", text) + self.assertIn(str(home), text) + self.assertIn("export PATH=", text) + self.assertIn("/tmp/venv/bin:/usr/bin", text) + self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) + + def test_upsert_cron_line_keeps_different_homes_separate(self): + line_a = render_cron_line(home="/tmp/home-a", hostname="host-a") + line_b = render_cron_line(home="/tmp/home-b", hostname="host-a") + updated, changed = _upsert_cron_line("", line_a, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + updated, changed = _upsert_cron_line(updated, line_b, "codexapi-agent::host-a::bbb") + self.assertTrue(changed) + self.assertIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + + def test_install_cron_writes_wrapper_and_updates_crontab_text(self): + writes = [] + + def fake_read(): + return "" + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + wrapper = Path(result["wrapper"]) + self.assertTrue(wrapper.exists()) + self.assertEqual(len(writes), 1) + self.assertIn(str(wrapper), writes[0]) + self.assertIn("codexapi-agent::host-a::", writes[0]) + if __name__ == "__main__": unittest.main() From 52c50153c5a7186d725a91affc7593af9a6b8c2d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:15:20 +0100 Subject: [PATCH 04/31] Improve agent scheduler visibility and install flow --- src/codexapi/agents.py | 33 ++++++++++++++++++++++++++++++++- tests/test_agents.py | 7 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 066e683..ee2cf8c 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -206,6 +206,7 @@ def show_agent(agent_ref, home=None): snapshot = _snapshot(agent_dir) snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) return snapshot @@ -218,6 +219,16 @@ def read_agent(agent_ref, limit=10, home=None): state = _read_json(agent_dir / "state.json") session = _read_session(agent_dir) items = [] + for queued in _queued_send_commands(agent_dir): + text = queued.get("body") or "" + if text: + items.append( + { + "kind": "queued", + "timestamp": queued.get("created_at") or "", + "text": text, + } + ) for run in _recent_runs(agent_dir, limit): reply = run.get("reply") or "" if reply: @@ -682,6 +693,9 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): def _snapshot(agent_dir): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + unread = int(state.get("unread_message_count") or 0) + len( + _queued_send_commands(agent_dir) + ) return { "id": meta["id"], "name": meta["name"], @@ -697,7 +711,7 @@ def _snapshot(agent_dir): "last_success_at": state.get("last_success_at") or "", "next_wake_at": state.get("next_wake_at") or "", "wake_requested_at": state.get("wake_requested_at") or "", - "unread_message_count": int(state.get("unread_message_count") or 0), + "unread_message_count": unread, "input_tokens": int(state.get("input_tokens") or 0), "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), @@ -918,6 +932,23 @@ def _wake_reason(state, commands): return ",".join(sorted(set(reasons))) +def _queued_send_commands(agent_dir): + queued = [] + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return queued + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file() or path.suffix != ".json": + continue + try: + payload = _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + continue + if payload.get("kind") == "send": + queued.append(payload) + return queued + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/tests/test_agents.py b/tests/test_agents.py index 15ebc34..a1fa6b4 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -65,6 +65,13 @@ def fake_runner(meta, session, prompt): agent = start_agent("Handle background work.", hostname="host-a") send_agent(agent["id"], "status", author="mark", hostname="host-b") + before = show_agent(agent["id"]) + self.assertEqual(before["unread_message_count"], 1) + self.assertEqual(before["state"]["unread_message_count"], 1) + queued = read_agent(agent["id"]) + self.assertEqual(queued["items"][0]["kind"], "queued") + self.assertEqual(queued["items"][0]["text"], "status") + other_host = tick(hostname="host-b", runner=fake_runner) self.assertTrue(other_host["ran"]) self.assertEqual(other_host["processed"], 0) From 75d7f7e3abb36492975d7312df4cf33d2fd8af0d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 08:54:38 +0100 Subject: [PATCH 05/31] Add immediate nudges and token accounting --- src/codexapi/agent.py | 89 +++++++++++++++++++++++++++++++++-- src/codexapi/agents.py | 92 ++++++++++++++++++++++++++++++++++++- src/codexapi/cli.py | 3 ++ tests/test_agent_backend.py | 83 +++++++++++++++++++++++++++++++++ tests/test_agents.py | 48 +++++++++++++++++++ 5 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 tests/test_agent_backend.py diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 6ab2f11..0b87b14 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -47,7 +47,7 @@ def agent( Returns: The agent's visible response text with reasoning traces removed. """ - message, _thread_id = _run_agent( + message, _thread_id, _usage = _run_agent( prompt, cwd, None, yolo, flags, include_thinking, backend, env ) return message @@ -103,12 +103,13 @@ def __init__( self.thread_id = thread_id self._backend = backend self._env = env + self.last_usage = {} def __call__(self, prompt): """Send a prompt to the agent backend and return the message.""" if self._welfare: prompt = welfare.append_instructions(prompt) - message, thread_id = _run_agent( + message, thread_id, usage = _run_agent( prompt, self.cwd, self.thread_id, @@ -120,6 +121,7 @@ def __call__(self, prompt): ) if thread_id: self.thread_id = thread_id + self.last_usage = usage or {} if self._welfare and welfare.stop_requested(message): raise WelfareStop(message) return message @@ -213,6 +215,7 @@ def _parse_jsonl(output, include_thinking): thread_id = None messages = [] raw_lines = [] + usage = {} for line in output.splitlines(): line = line.strip() @@ -229,6 +232,10 @@ def _parse_jsonl(output, include_thinking): if isinstance(maybe_thread, str): thread_id = maybe_thread + maybe_usage = _event_usage(event) + if maybe_usage: + usage = maybe_usage + if event.get("type") == "item.completed": item = event.get("item") or {} if item.get("type") == "agent_message": @@ -243,8 +250,8 @@ def _parse_jsonl(output, include_thinking): ) if include_thinking: - return "\n\n".join(messages), thread_id - return messages[-1], thread_id + return "\n\n".join(messages), thread_id, usage + return messages[-1], thread_id, usage def _parse_cursor_json(output, include_thinking): @@ -292,7 +299,7 @@ def _parse_cursor_json(output, include_thinking): session_id = payload.get("session_id") if not isinstance(session_id, str): session_id = None - return result, session_id + return result, session_id, {} def _merged_env(env): @@ -308,3 +315,75 @@ def _merged_env(env): else: merged[str(key)] = str(value) return merged + + +def _event_usage(event): + """Extract per-call token usage from a backend event when present.""" + if not isinstance(event, dict): + return {} + event_type = event.get("type") + payload = None + if event_type == "event_msg": + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + return {} + info = payload.get("info") or {} + usage = info.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = info.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + if event_type == "token_count": + payload = event.get("info") or event.get("payload") or {} + usage = payload.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = payload.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + + +def _normalize_usage(usage): + """Normalize token usage dicts to input/output/total ints.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int( + usage.get("input_tokens"), + usage.get("prompt_tokens"), + usage.get("input"), + ) + output_tokens = _usage_int( + usage.get("output_tokens"), + usage.get("completion_tokens"), + usage.get("output"), + ) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _usage_int(*values): + """Return the first integer-like usage value from the given candidates.""" + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index ee2cf8c..c39c9d8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -280,6 +280,22 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): + """Attempt an immediate wake for one locally-owned agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + outcome = _tick_agent(agent_dir, now or utc_now(), runner) + return { + "ran": True, + "reason": "local", + "processed": 1 if outcome["processed"] else 0, + "woken": 1 if outcome["woken"] else 0, + } + + def tick(home=None, hostname=None, now=None, runner=None): """Process due agents for the current host.""" home = _resolve_home(home) @@ -431,6 +447,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): "notify": "", "error": "", "continue": True, + "usage": {}, } try: outcome = _run_agent_turn(meta, session, prompt, runner) @@ -438,6 +455,8 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): ended = utc_now() session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" session["pending_messages"] = [] + usage = _normalize_usage(outcome.get("usage")) + _add_usage(meta, state, usage, ended) state["reply"] = response["reply"] state["last_success_at"] = format_utc(ended) state["last_error"] = "" @@ -460,6 +479,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): run["reply"] = response["reply"] run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) + run["usage"] = usage _write_run(agent_dir, meta["hostname"], run) if response["notify"]: title = f"Agent: {meta['name']}" @@ -483,7 +503,10 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): def _run_agent_turn(meta, session, prompt, runner=None): if runner is not None: - return runner(meta, session, prompt) + outcome = runner(meta, session, prompt) + if not isinstance(outcome, dict): + raise TypeError("runner must return a dict") + return outcome worker = Agent( session.get("cwd") or meta.get("cwd"), session.get("yolo", True), @@ -494,7 +517,11 @@ def _run_agent_turn(meta, session, prompt, runner=None): env=session.get("env") or None, ) message = worker(prompt) - return {"message": message, "thread_id": worker.thread_id or ""} + return { + "message": message, + "thread_id": worker.thread_id or "", + "usage": worker.last_usage or {}, + } def _parse_agent_response(output): @@ -932,6 +959,67 @@ def _wake_reason(state, commands): return ",".join(sorted(set(reasons))) +def _normalize_usage(usage): + """Normalize usage dicts for state accounting.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int(usage.get("input_tokens")) + output_tokens = _usage_int(usage.get("output_tokens")) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _add_usage(meta, state, usage, now): + """Accumulate token usage totals and refresh the running average.""" + if not usage: + return + input_tokens = usage.get("input_tokens") + output_tokens = usage.get("output_tokens") + total_tokens = usage.get("total_tokens") + if input_tokens is not None: + state["input_tokens"] = int(state.get("input_tokens") or 0) + input_tokens + if output_tokens is not None: + state["output_tokens"] = int(state.get("output_tokens") or 0) + output_tokens + if total_tokens is None: + total_tokens = 0 + if input_tokens is not None: + total_tokens += input_tokens + if output_tokens is not None: + total_tokens += output_tokens + state["total_tokens"] = int(state.get("total_tokens") or 0) + total_tokens + created_at = parse_utc(meta.get("created_at")) + if created_at is None: + return + elapsed = (now - created_at).total_seconds() + if elapsed <= 0: + elapsed = 1 + state["avg_tokens_per_hour"] = round(state["total_tokens"] * 3600.0 / elapsed, 2) + + +def _usage_int(value): + """Return an integer-like usage value or None.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None + + def _queued_send_commands(agent_dir): queued = [] new_dir = agent_dir / "commands" / "new" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 8bb8a9a..9495ca0 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -17,6 +17,7 @@ control_agent, install_cron as install_agent_cron, list_agents as list_managed_agents, + nudge_agent, read_agent as read_managed_agent, send_agent, show_agent as show_managed_agent, @@ -1605,6 +1606,7 @@ def main(argv=None): return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("wake", "pause", "resume", "cancel"): @@ -1613,6 +1615,7 @@ def main(argv=None): args.agent_command, args.author, ) + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "tick": diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py new file mode 100644 index 0000000..ae443b3 --- /dev/null +++ b/tests/test_agent_backend.py @@ -0,0 +1,83 @@ +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.agent import _parse_jsonl + + +class AgentBackendTests(unittest.TestCase): + def test_parse_jsonl_extracts_last_token_usage(self): + output = "\n".join( + [ + json.dumps({"type": "thread.started", "thread_id": "thread-1"}), + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 12, + "output_tokens": 7, + "total_tokens": 19, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "hello"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "hello") + self.assertEqual(thread_id, "thread-1") + self.assertEqual( + usage, + {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19}, + ) + + def test_parse_jsonl_falls_back_to_total_token_usage(self): + output = "\n".join( + [ + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "prompt_tokens": 9, + "completion_tokens": 4, + "total_tokens": 13, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "done"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "done") + self.assertIsNone(thread_id) + self.assertEqual( + usage, + {"input_tokens": 9, "output_tokens": 4, "total_tokens": 13}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agents.py b/tests/test_agents.py index a1fa6b4..04e66bc 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -4,6 +4,7 @@ import tempfile import unittest from contextlib import contextmanager +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -15,6 +16,7 @@ _upsert_cron_line, control_agent, install_cron, + nudge_agent, read_agent, render_cron_line, send_agent, @@ -185,6 +187,52 @@ def fake_write(text): self.assertIn(str(wrapper), writes[0]) self.assertIn("codexapi-agent::host-a::", writes[0]) + def test_nudge_agent_runs_immediately_and_updates_token_totals(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-usage") + self.assertEqual(shown["state"]["input_tokens"], 30) + self.assertEqual(shown["state"]["output_tokens"], 20) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Message handled.") + self.assertEqual(shown["unread_message_count"], 0) + if __name__ == "__main__": unittest.main() From 1d234ac5565d78cb9ab62e2b2aab8800c95c788c Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 09:01:58 +0100 Subject: [PATCH 06/31] Improve agent transcripts and scheduler lifecycle --- src/codexapi/agents.py | 72 ++++++++++++++++++++++++++++++++++++++++-- src/codexapi/cli.py | 41 ++++++++++++++++++++++-- tests/test_agents.py | 72 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index c39c9d8..6a43afc 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -227,9 +227,21 @@ def read_agent(agent_ref, limit=10, home=None): "kind": "queued", "timestamp": queued.get("created_at") or "", "text": text, + "author": queued.get("author") or "user", } ) for run in _recent_runs(agent_dir, limit): + for message in run.get("messages") or []: + text = message.get("text") or "" + if text: + items.append( + { + "kind": "user", + "timestamp": message.get("created_at") or "", + "text": text, + "author": message.get("author") or "user", + } + ) reply = run.get("reply") or "" if reply: items.append( @@ -247,14 +259,15 @@ def read_agent(agent_ref, limit=10, home=None): "kind": "pending", "timestamp": pending.get("created_at") or "", "text": text, + "author": pending.get("author") or "user", } ) - items.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + items.sort(key=lambda item: item.get("timestamp") or "") return { "id": meta["id"], "name": meta["name"], "status": state.get("status") or "", - "items": items[:limit], + "items": items[-limit:], } @@ -344,6 +357,28 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } +def uninstall_cron(home=None, hostname=None): + """Remove the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _remove_cron_line(existing, tag) + if changed: + _write_crontab(updated) + wrapper = home / "bin" / "agent-tick" + cron_record = home / "cron" / "agent.cron" + _remove_file(wrapper) + _remove_file(cron_record) + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "changed": changed, + } + + def resolve_agent_dir(agent_ref, home=None): """Resolve an agent by id, unique id prefix, or name.""" if not isinstance(agent_ref, str) or not agent_ref.strip(): @@ -442,6 +477,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): "ended_at": "", "wake_reason": _wake_reason(state, commands), "commands": [command["kind"] for command in commands], + "messages": [], "status": "", "reply": "", "notify": "", @@ -453,6 +489,16 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): outcome = _run_agent_turn(meta, session, prompt, runner) response = _parse_agent_response(outcome["message"]) ended = utc_now() + delivered_messages = [ + { + "id": message.get("id") or "", + "created_at": message.get("created_at") or "", + "author": message.get("author") or "user", + "text": message.get("text") or "", + } + for message in (session.get("pending_messages") or []) + if message.get("text") + ] session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" session["pending_messages"] = [] usage = _normalize_usage(outcome.get("usage")) @@ -480,6 +526,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) run["usage"] = usage + run["messages"] = delivered_messages _write_run(agent_dir, meta["hostname"], run) if response["notify"]: title = f"Agent: {meta['name']}" @@ -1064,6 +1111,20 @@ def _upsert_cron_line(existing, line, tag): return text, changed +def _remove_cron_line(existing, tag): + lines = [] + changed = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + changed = True + continue + lines.append(raw) + text = "\n".join(lines) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + def _read_crontab(): result = subprocess.run( ["crontab", "-l"], @@ -1088,3 +1149,10 @@ def _write_crontab(text): ) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or "crontab install failed") + + +def _remove_file(path): + try: + Path(path).unlink() + except FileNotFoundError: + return diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 9495ca0..2206674 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -23,6 +23,7 @@ show_agent as show_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, + uninstall_cron as uninstall_agent_cron, ) from .foreach import foreach from .ralph import Ralph, cancel_ralph_loop @@ -146,15 +147,18 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT HOST UNREAD TOKENS NAME") + print("ID STAT HOST UNREAD TOKENS TOK/H NAME") for item in items: ident = item["id"][:8] status = _truncate_head(item["status"] or "-", 8) host = _truncate_head(item["hostname"] or "-", 15) unread = str(item["unread_message_count"]) tokens = _format_token_total(item["total_tokens"]) + tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) name = item["name"] - print(f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {name}") + print( + f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {tok_h:>7} {name}" + ) def _print_managed_agent_read(result): @@ -166,7 +170,11 @@ def _print_managed_agent_read(result): for item in items: stamp = item.get("timestamp") or "-" kind = item.get("kind") or "item" - print(f"[{stamp}] {kind}:") + author = item.get("author") or "" + if author: + print(f"[{stamp}] {kind} {author}:") + else: + print(f"[{stamp}] {kind}:") print(item.get("text") or "") print() @@ -475,6 +483,26 @@ def _format_token_total(value): return str(value) +def _format_token_rate(value): + if value is None: + return "-" + try: + value = float(value) + except (TypeError, ValueError): + return "-" + if value < 0: + return "-" + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}m" + if value >= 1_000: + return f"{value / 1_000:.1f}k" + if value >= 100: + return f"{value:.0f}" + if value >= 10: + return f"{value:.1f}" + return f"{value:.2f}" + + def _format_duration(seconds): if seconds is None: return "-" @@ -1278,6 +1306,10 @@ def main(argv=None): "install-cron", help="Install or update the cron entry for this CODEXAPI_HOME.", ) + agent_subparsers.add_parser( + "uninstall-cron", + help="Remove the cron entry for this CODEXAPI_HOME.", + ) task_parser = subparsers.add_parser( "task", @@ -1624,6 +1656,9 @@ def main(argv=None): if args.agent_command == "install-cron": print(json.dumps(install_agent_cron(), indent=2, sort_keys=True)) return + if args.agent_command == "uninstall-cron": + print(json.dumps(uninstall_agent_cron(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 04e66bc..0354ea9 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -13,6 +13,7 @@ from codexapi.agents import ( _tick_lock_path, _try_lock, + _remove_cron_line, _upsert_cron_line, control_agent, install_cron, @@ -23,6 +24,7 @@ show_agent, start_agent, tick, + uninstall_cron, write_tick_wrapper, ) @@ -92,8 +94,11 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["unread_message_count"], 0) conversation = read_agent(agent["id"]) - self.assertEqual(conversation["items"][0]["kind"], "agent") - self.assertEqual(conversation["items"][0]["text"], "I saw your message.") + self.assertEqual(conversation["items"][0]["kind"], "user") + self.assertEqual(conversation["items"][0]["author"], "mark") + self.assertEqual(conversation["items"][0]["text"], "status") + self.assertEqual(conversation["items"][1]["kind"], "agent") + self.assertEqual(conversation["items"][1]["text"], "I saw your message.") def test_pause_then_resume(self): calls = [] @@ -163,6 +168,16 @@ def test_upsert_cron_line_keeps_different_homes_separate(self): self.assertIn("/tmp/home-a/bin/agent-tick", updated) self.assertIn("/tmp/home-b/bin/agent-tick", updated) + def test_remove_cron_line_keeps_other_entries(self): + existing = ( + "* * * * * /tmp/home-a/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::aaa\n" + "* * * * * /tmp/home-b/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::bbb\n" + ) + updated, changed = _remove_cron_line(existing, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + self.assertNotIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + def test_install_cron_writes_wrapper_and_updates_crontab_text(self): writes = [] @@ -187,6 +202,59 @@ def fake_write(text): self.assertIn(str(wrapper), writes[0]) self.assertIn("codexapi-agent::host-a::", writes[0]) + def test_install_cron_is_idempotent_when_line_already_matches(self): + writes = [] + + with _temp_home() as home: + expected_line = render_cron_line(home=home, hostname="host-a") + + def fake_read(): + return expected_line + "\n" + + def fake_write(text): + writes.append(text) + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + self.assertFalse(result["changed"]) + self.assertEqual(writes, []) + + def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self): + writes = [] + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + record = home / "cron" / "agent.cron" + record.write_text("placeholder\n", encoding="utf-8") + this_line = render_cron_line(home=home, hostname="host-a") + other_line = render_cron_line(home="/tmp/other-home", hostname="host-a") + + def fake_read(): + return this_line + "\n" + other_line + "\n" + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = uninstall_cron(home=home, hostname="host-a") + self.assertTrue(result["changed"]) + self.assertFalse(wrapper.exists()) + self.assertFalse(record.exists()) + self.assertEqual(len(writes), 1) + self.assertNotIn(str(wrapper), writes[0]) + self.assertIn("/tmp/other-home/bin/agent-tick", writes[0]) + def test_nudge_agent_runs_immediately_and_updates_token_totals(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(hours=1) From 77d4ab6beb54b6c9f7935a6c7eb5c7d8a60495a6 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 09:30:07 +0100 Subject: [PATCH 07/31] Use rollout logs for agent usage and improve views --- src/codexapi/agents.py | 86 +++++++++++++++- src/codexapi/cli.py | 122 ++++++++++++++++++++++- tests/test_agents.py | 216 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 419 insertions(+), 5 deletions(-) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 6a43afc..cffb475 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -135,6 +135,7 @@ def start_agent( cwd = _resolve_cwd(cwd) session = { "thread_id": "", + "rollout_path": "", "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), "yolo": bool(yolo), "flags": flags or "", @@ -500,6 +501,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): if message.get("text") ] session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" + session["rollout_path"] = outcome.get("rollout_path") or session.get("rollout_path") or "" session["pending_messages"] = [] usage = _normalize_usage(outcome.get("usage")) _add_usage(meta, state, usage, ended) @@ -554,6 +556,7 @@ def _run_agent_turn(meta, session, prompt, runner=None): if not isinstance(outcome, dict): raise TypeError("runner must return a dict") return outcome + started = utc_now() worker = Agent( session.get("cwd") or meta.get("cwd"), session.get("yolo", True), @@ -564,10 +567,21 @@ def _run_agent_turn(meta, session, prompt, runner=None): env=session.get("env") or None, ) message = worker(prompt) + usage = worker.last_usage or {} + rollout_path = "" + if (session.get("backend") or "codex") == "codex": + rollout_usage, rollout_path = _codex_rollout_usage( + session, + worker.thread_id or session.get("thread_id") or "", + started, + ) + if rollout_usage: + usage = rollout_usage return { "message": message, "thread_id": worker.thread_id or "", - "usage": worker.last_usage or {}, + "usage": usage, + "rollout_path": rollout_path, } @@ -1084,6 +1098,76 @@ def _queued_send_commands(agent_dir): return queued +def _codex_rollout_usage(session, thread_id, started_at): + """Return usage from the current Codex rollout plus its resolved path.""" + if not thread_id: + return {}, "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is None: + return {}, "" + usage = _extract_rollout_usage(rollout_path, started_at) + if not usage: + return {}, str(rollout_path) + return usage, str(rollout_path) + + +def _resolve_rollout_path(known_path, thread_id): + """Return the rollout file for a thread, preferring the cached session path.""" + if known_path: + path = Path(known_path) + if path.exists() and thread_id in path.name: + return path + root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions" + if not root.exists(): + return None + candidates = [] + for dirpath, _dirnames, filenames in os.walk(root): + for name in filenames: + if not name.startswith("rollout-") or not name.endswith(".jsonl"): + continue + if thread_id not in name: + continue + path = Path(dirpath) / name + try: + mtime = path.stat().st_mtime + except OSError: + continue + candidates.append((mtime, path)) + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +def _extract_rollout_usage(path, started_at): + """Return the latest per-turn token usage written after this wake started.""" + latest = {} + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if '"token_count"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "event_msg": + continue + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + continue + timestamp = parse_utc(event.get("timestamp")) + if timestamp is not None and timestamp < started_at: + continue + info = payload.get("info") or {} + usage = _normalize_usage(info.get("last_token_usage")) + if usage: + latest = usage + except OSError: + return {} + return latest + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 2206674..9623c82 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -147,17 +147,20 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT HOST UNREAD TOKENS TOK/H NAME") + print("ID STAT POL HOST UNR TOKENS TOK/H NEXT REPO NAME") for item in items: ident = item["id"][:8] status = _truncate_head(item["status"] or "-", 8) - host = _truncate_head(item["hostname"] or "-", 15) + policy = _truncate_head(_policy_label(item.get("stop_policy")), 4) + host = _truncate_head(item["hostname"] or "-", 12) unread = str(item["unread_message_count"]) tokens = _format_token_total(item["total_tokens"]) tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) + next_wake = _truncate_head(_next_wake_label(item), 6) + repo = _truncate_head(_repo_label(item.get("cwd")), 12) name = item["name"] print( - f"{ident:<8} {status:<8} {host:<15} {unread:>6} {tokens:>6} {tok_h:>7} {name}" + f"{ident:<8} {status:<8} {policy:<4} {host:<12} {unread:>3} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" ) @@ -179,6 +182,42 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_show(result): + meta = result["meta"] + state = result["state"] + print(f"{meta['name']} [{state.get('status') or '-'}]") + print(f"ID: {meta['id']}") + print(f"Host: {meta['hostname']}") + print(f"Created: {meta['created_at']} by {meta['created_by']}") + print( + f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" + ) + print(f"CWD: {meta['cwd']}") + print(f"Thread: {state.get('thread_id') or '-'}") + print( + "Tokens: " + f"{_format_token_total(state.get('total_tokens'))} total " + f"({_format_token_total(state.get('input_tokens'))} in, " + f"{_format_token_total(state.get('output_tokens'))} out, " + f"{_format_token_rate(state.get('avg_tokens_per_hour'))}/h)" + ) + print(f"Activity: {_state_text(state.get('activity'))}") + print(f"Reply: {_state_text(state.get('reply'))}") + print(f"Last error: {_state_text(state.get('last_error'))}") + print(f"Last wake: {_state_time(state.get('last_wake_at'))}") + print(f"Last success: {_state_time(state.get('last_success_at'))}") + print(f"Next wake: {_state_time(state.get('next_wake_at'))}") + print(f"Wake requested: {_state_time(state.get('wake_requested_at'))}") + print(f"Prompt: {_truncate_head(_single_line(meta.get('prompt') or ''), 160) or '-'}") + recent_runs = result.get("recent_runs") or [] + if not recent_runs: + return + print() + print("Recent runs:") + for run in recent_runs: + print(_format_managed_agent_run(run)) + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -218,6 +257,81 @@ def _truncate_tail(text, limit): return "..." + text[-(limit - 3) :] +def _repo_label(cwd): + if not isinstance(cwd, str) or not cwd: + return "-" + name = Path(cwd).name + return name or cwd + + +def _policy_label(stop_policy): + if stop_policy == "until_done": + return "done" + if stop_policy == "until_stopped": + return "loop" + return "-" + + +def _next_wake_label(item): + status = item.get("status") or "" + if status in ("done", "canceled"): + return "-" + if status == "paused": + return "paused" + if item.get("wake_requested_at"): + return "wake" + next_wake = _parse_timestamp(item.get("next_wake_at")) + if next_wake is None: + return "-" + now = datetime.now() + if next_wake <= now: + return "due" + return _short_duration((next_wake - now).total_seconds()) + + +def _short_duration(seconds): + if seconds <= 0: + return "due" + total = int(seconds) + days, rem = divmod(total, 86400) + if days: + return f"{days}d" + hours, rem = divmod(rem, 3600) + if hours: + return f"{hours}h" + minutes, secs = divmod(rem, 60) + if minutes: + return f"{minutes}m" + return f"{secs}s" + + +def _state_text(value): + text = _single_line(str(value or "")) + return text or "-" + + +def _state_time(value): + return value or "-" + + +def _format_managed_agent_run(run): + started = run.get("started_at") or "-" + reason = run.get("wake_reason") or "-" + usage = run.get("usage") or {} + tokens = _format_token_total(usage.get("total_tokens")) + status = run.get("error") or run.get("status") or "-" + reply = run.get("reply") or "" + message_count = len(run.get("messages") or []) + parts = [started, reason, tokens] + if message_count: + parts.append(f"msgs={message_count}") + summary = _truncate_head(_single_line(status), 60) + if reply: + summary = _truncate_head(f"{summary} | {_single_line(reply)}", 100) + parts.append(summary) + return "- " + " ".join(parts) + + def _parse_timestamp(value): if not isinstance(value, str): return None @@ -1629,7 +1743,7 @@ def main(argv=None): _print_managed_agent_list(list_managed_agents()) return if args.agent_command == "show": - print(json.dumps(show_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + _print_managed_agent_show(show_managed_agent(args.agent_ref)) return if args.agent_command == "read": if args.limit < 1: diff --git a/tests/test_agents.py b/tests/test_agents.py index 0354ea9..96ee9c2 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,9 +1,11 @@ +import io import json import os import sys import tempfile import unittest from contextlib import contextmanager +from contextlib import redirect_stdout from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -11,11 +13,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from codexapi.agents import ( + _codex_rollout_usage, _tick_lock_path, _try_lock, _remove_cron_line, _upsert_cron_line, control_agent, + format_utc, install_cron, nudge_agent, read_agent, @@ -27,6 +31,7 @@ uninstall_cron, write_tick_wrapper, ) +from codexapi.cli import _print_managed_agent_list, _print_managed_agent_show @contextmanager @@ -301,6 +306,217 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["reply"], "Message handled.") self.assertEqual(shown["unread_message_count"], 0) + def test_codex_rollout_usage_uses_latest_event_after_start(self): + started = datetime(2026, 3, 6, 8, 0, 5, tzinfo=timezone.utc) + with _temp_home() as home: + codex_home = home / "codex-home" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": "2026-03-06T08:00:01Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:06Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 20, + "output_tokens": 10, + "total_tokens": 30, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:07Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 12, + "total_tokens": 52, + } + }, + }, + }, + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + usage, path = _codex_rollout_usage( + {"rollout_path": str(rollout)}, + "thread-rollout", + started, + ) + self.assertEqual(path, str(rollout)) + self.assertEqual( + usage, + {"input_tokens": 40, "output_tokens": 12, "total_tokens": 52}, + ) + + def test_nudge_agent_reads_usage_from_codex_rollout(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + with _temp_home() as home: + codex_home = home / "codex-home" + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-rollout" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": format_utc(start + timedelta(seconds=5)), + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 10, + "total_tokens": 50, + } + }, + }, + } + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + return json.dumps( + { + "status": "Handled from rollout", + "continue": False, + "reply": "Used rollout tokens.", + } + ) + + agent = start_agent( + "Handle with real rollout accounting.", + hostname="host-a", + now=start, + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + with patch("codexapi.agents.Agent", FakeAgent): + with patch("codexapi.agents.utc_now", side_effect=[start, end]): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-rollout") + self.assertEqual(shown["state"]["input_tokens"], 40) + self.assertEqual(shown["state"]["output_tokens"], 10) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Used rollout tokens.") + self.assertIn("thread-rollout", shown["session"]["rollout_path"]) + + def test_cli_managed_agent_views_show_operator_fields(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + shown = show_agent(agent["id"]) + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("POL", list_out.getvalue()) + self.assertIn("REPO", list_out.getvalue()) + self.assertIn("done", list_out.getvalue()) + self.assertIn("codexapi", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("Policy: until_done", text) + self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text) + self.assertIn("Prompt: Handle messages.", text) + self.assertIn("Recent runs:", text) + self.assertIn("msgs=1", text) + if __name__ == "__main__": unittest.main() From fbde70672187807b2ce9b2ff409c10fa528df134 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 10:29:54 +0100 Subject: [PATCH 08/31] Add stable hostname override for agents --- docs/agent-v1.md | 23 +++++++++++++++++++++++ src/codexapi/agents.py | 9 +++++++-- tests/test_agents.py | 8 ++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 16026ce..1dedc2c 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -75,6 +75,28 @@ Why this exists: Two different `CODEXAPI_HOME` values are two different systems. They do not see each other's agents, locks, scheduler wrappers, or cron entries. +## `CODEXAPI_HOSTNAME` + +`CODEXAPI_HOSTNAME` overrides the host identity used for agent ownership and +host-specific locks. + +Default: + +- use the process hostname reported by the OS + +Override: + +```text +CODEXAPI_HOSTNAME=stable-hostname +``` + +Why this exists: + +- Some shells, cron environments, test harnesses, or sandboxes report different + hostnames for the same machine. +- Agent ownership depends on an exact hostname match. +- Tests and sandboxed runs need a stable explicit value. + ## Agent Model Each agent stores at least: @@ -364,6 +386,7 @@ Why it exists: The wrapper should: - export the resolved `CODEXAPI_HOME` +- export the resolved `CODEXAPI_HOSTNAME` - set a safe `PATH` - invoke the exact Python interpreter or installed `codexapi` path discovered at install time diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index cffb475..86b7d4a 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -57,6 +57,9 @@ def codexapi_home(): def current_hostname(): """Return the current hostname.""" + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if override: + return override name = socket.gethostname().strip() return name or "unknown-host" @@ -341,7 +344,7 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No _ensure_home(home) python_executable = python_executable or sys.executable path_value = path_value or os.environ.get("PATH", "") - wrapper = write_tick_wrapper(home, python_executable, path_value) + wrapper = write_tick_wrapper(home, python_executable, path_value, host) cron_line = render_cron_line(home, host) tag = _cron_tag(home, host) existing = _read_crontab() @@ -403,16 +406,18 @@ def resolve_agent_dir(agent_ref, home=None): return _agent_dir(home, matches[0]) -def write_tick_wrapper(home=None, python_executable=None, path_value=None): +def write_tick_wrapper(home=None, python_executable=None, path_value=None, hostname=None): """Write the cron wrapper script and return its path.""" home = _resolve_home(home) _ensure_home(home) python_executable = python_executable or sys.executable path_value = path_value or os.environ.get("PATH", "") + hostname = hostname or current_hostname() wrapper = home / "bin" / "agent-tick" lines = [ "#!/bin/bash", f"export CODEXAPI_HOME={shlex.quote(str(home))}", + f"export CODEXAPI_HOSTNAME={shlex.quote(str(hostname))}", f"export PATH={shlex.quote(path_value)}", f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", ] diff --git a/tests/test_agents.py b/tests/test_agents.py index 96ee9c2..ce2c422 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -42,6 +42,12 @@ def _temp_home(): class AgentsTests(unittest.TestCase): + def test_current_hostname_prefers_override(self): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + from codexapi.agents import current_hostname + + self.assertEqual(current_hostname(), "stable-host") + def test_homes_are_isolated(self): with _temp_home() as home_a: first = start_agent("Monitor the build queue.", hostname="host-a") @@ -155,10 +161,12 @@ def test_write_tick_wrapper_pins_home_and_python(self): home=home, python_executable="/tmp/venv/bin/python", path_value="/tmp/venv/bin:/usr/bin", + hostname="stable-host", ) text = wrapper.read_text(encoding="utf-8") self.assertIn("export CODEXAPI_HOME=", text) self.assertIn(str(home), text) + self.assertIn("export CODEXAPI_HOSTNAME=stable-host", text) self.assertIn("export PATH=", text) self.assertIn("/tmp/venv/bin:/usr/bin", text) self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) From cbbad614e90301f11752a9c282fa751367d7726a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 6 Mar 2026 11:03:16 +0100 Subject: [PATCH 09/31] Improve agent orchestration and identity tooling --- docs/agent-v1.md | 17 ++++++- src/codexapi/agents.py | 95 +++++++++++++++++++++++++++++++++++-- src/codexapi/cli.py | 41 ++++++++++++++++ tests/test_agents.py | 104 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 250 insertions(+), 7 deletions(-) diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 1dedc2c..9bbb32a 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -105,6 +105,7 @@ Each agent stores at least: - `name`: human-readable unique name within the home - `created_at`: UTC timestamp - `created_by`: user name or parent agent name +- `parent_id`: parent agent id, if any - `hostname`: owning host for execution - `cwd`: working directory - `prompt`: original instruction text @@ -196,7 +197,7 @@ Why it exists: - Separates mostly-static configuration from rapidly changing state. Suggested contents: -- `id`, `name`, `created_at`, `created_by`, `hostname`, `cwd`, `prompt`, +- `id`, `name`, `created_at`, `created_by`, `parent_id`, `hostname`, `cwd`, `prompt`, `stop_policy`, `heartbeat_minutes` ### `agents//state.json` @@ -600,6 +601,18 @@ Why this matters: V1 should store only the minimum needed to recreate the expected environment. +Managed wakes should also expose stable agent identity to the backend process: + +- `CODEXAPI_AGENT_ID` +- `CODEXAPI_AGENT_NAME` +- `CODEXAPI_AGENT_PARENT_ID`, when relevant + +Why this matters: + +- a managed agent should be able to start another agent without manually + re-stating its own identity +- child agents should be able to record parentage automatically + ## Token Accounting V1 should not pretend to know dollar cost. @@ -627,6 +640,7 @@ V1 CLI surface: - `codexapi agent start` - `codexapi agent list` +- `codexapi agent whoami` - `codexapi agent read` - `codexapi agent show` - `codexapi agent send` @@ -641,6 +655,7 @@ Expected behavior: - `start` creates the agent directory, meta/state files, and host runtime files - `list` reads only this `CODEXAPI_HOME` +- `whoami` prints the effective host identity and `CODEXAPI_HOME` - `read` shows recent user-visible communication derived from state and run records - `show` reads one agent's current snapshot and recent run history diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 86b7d4a..95c4fbf 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -100,6 +100,7 @@ def start_agent( cwd=None, name=None, created_by=None, + parent_ref=None, stop_policy="until_done", heartbeat_minutes=5, backend=None, @@ -133,8 +134,9 @@ def start_agent( commands_claimed.mkdir(parents=True, exist_ok=False) runs_dir.mkdir(parents=True, exist_ok=False) + parent_id, parent_name = _parent_identity(home, parent_ref) if created_by is None: - created_by = os.environ.get("USER") or "user" + created_by = parent_name or os.environ.get("CODEXAPI_AGENT_NAME") or os.environ.get("USER") or "user" cwd = _resolve_cwd(cwd) session = { "thread_id": "", @@ -152,6 +154,7 @@ def start_agent( "name": agent_name, "created_at": format_utc(now), "created_by": str(created_by), + "parent_id": parent_id, "hostname": host, "cwd": cwd, "prompt": prompt.strip(), @@ -192,12 +195,13 @@ def list_agents(home=None): root = home / "agents" if not root.exists(): return [] + child_map = _child_map(home) agents = [] for agent_dir in root.iterdir(): if not agent_dir.is_dir(): continue try: - agents.append(_snapshot(agent_dir)) + agents.append(_snapshot(agent_dir, child_map)) except FileNotFoundError: continue agents.sort(key=lambda item: item["created_at"], reverse=True) @@ -206,13 +210,18 @@ def list_agents(home=None): def show_agent(agent_ref, home=None): """Return a full agent snapshot.""" + home = _resolve_home(home) + child_map = _child_map(home) agent_dir = resolve_agent_dir(agent_ref, home) - snapshot = _snapshot(agent_dir) + snapshot = _snapshot(agent_dir, child_map) snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) + snapshot["parent"] = _agent_brief(home, snapshot["parent_id"], child_map) + snapshot["children"] = _agent_briefs(home, snapshot["child_ids"], child_map) return snapshot @@ -569,7 +578,7 @@ def _run_agent_turn(meta, session, prompt, runner=None): session.get("flags") or None, include_thinking=False, backend=session.get("backend") or None, - env=session.get("env") or None, + env=_agent_env(meta, session), ) message = worker(prompt) usage = worker.last_usage or {} @@ -783,9 +792,13 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): return payload -def _snapshot(agent_dir): +def _snapshot(agent_dir, child_map=None): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + if child_map is None: + child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) + else: + child_ids = child_map.get(meta["id"], []) unread = int(state.get("unread_message_count") or 0) + len( _queued_send_commands(agent_dir) ) @@ -794,6 +807,7 @@ def _snapshot(agent_dir): "name": meta["name"], "created_at": meta["created_at"], "created_by": meta["created_by"], + "parent_id": meta.get("parent_id") or "", "hostname": meta["hostname"], "cwd": meta["cwd"], "stop_policy": meta["stop_policy"], @@ -809,6 +823,7 @@ def _snapshot(agent_dir): "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), "avg_tokens_per_hour": float(state.get("avg_tokens_per_hour") or 0.0), + "child_ids": list(child_ids), "last_error": state.get("last_error") or "", "activity": state.get("activity") or "", "reply": state.get("reply") or "", @@ -865,6 +880,18 @@ def _capture_env(): return env +def _parent_identity(home, parent_ref): + """Return the resolved parent agent id and name, if any.""" + if parent_ref is not None and str(parent_ref).strip(): + meta = _read_json(resolve_agent_dir(str(parent_ref), home) / "meta.json") + return meta["id"], meta["name"] + parent_id = os.environ.get("CODEXAPI_AGENT_ID", "").strip() + parent_name = os.environ.get("CODEXAPI_AGENT_NAME", "").strip() + if parent_id: + return parent_id, parent_name + return "", "" + + def _resolve_home(home): if home is None: return codexapi_home() @@ -917,6 +944,16 @@ def _sync_state_from_session(state, session): state["thread_id"] = session.get("thread_id") or "" +def _agent_env(meta, session): + """Return the backend env with stable agent identity added.""" + env = dict(session.get("env") or {}) + env["CODEXAPI_AGENT_ID"] = meta["id"] + env["CODEXAPI_AGENT_NAME"] = meta["name"] + if meta.get("parent_id"): + env["CODEXAPI_AGENT_PARENT_ID"] = meta["parent_id"] + return env + + def _command_id(now, hostname): stamp = now.strftime("%Y%m%dT%H%M%SZ") rand = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) @@ -1103,6 +1140,54 @@ def _queued_send_commands(agent_dir): return queued +def _child_map(home): + """Return parent_id -> [child ids] for this home.""" + root = _resolve_home(home) / "agents" + child_map = {} + if not root.exists(): + return child_map + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + meta = _read_json(agent_dir / "meta.json") + except FileNotFoundError: + continue + parent_id = meta.get("parent_id") or "" + if not parent_id: + continue + child_map.setdefault(parent_id, []).append(meta["id"]) + for child_ids in child_map.values(): + child_ids.sort() + return child_map + + +def _agent_brief(home, agent_id, child_map): + """Return a short snapshot for one related agent.""" + if not agent_id: + return None + try: + agent_dir = resolve_agent_dir(agent_id, home) + except ValueError: + return None + snapshot = _snapshot(agent_dir, child_map) + return { + "id": snapshot["id"], + "name": snapshot["name"], + "status": snapshot["status"], + "reply": snapshot["reply"], + } + + +def _agent_briefs(home, agent_ids, child_map): + """Return short snapshots for a list of related agents.""" + return [ + brief + for brief in (_agent_brief(home, agent_id, child_map) for agent_id in agent_ids or []) + if brief is not None + ] + + def _codex_rollout_usage(session, thread_id, started_at): """Return usage from the current Codex rollout plus its resolved path.""" if not thread_id: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 9623c82..3b8a0fc 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -14,7 +14,9 @@ from .agent import Agent, agent from .agents import ( + codexapi_home, control_agent, + current_hostname, install_cron as install_agent_cron, list_agents as list_managed_agents, nudge_agent, @@ -182,6 +184,13 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_identity(): + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + print(f"Host: {current_hostname()}") + print(f"Host override: {override or '-'}") + print(f"Home: {codexapi_home()}") + + def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] @@ -189,6 +198,8 @@ def _print_managed_agent_show(result): print(f"ID: {meta['id']}") print(f"Host: {meta['hostname']}") print(f"Created: {meta['created_at']} by {meta['created_by']}") + print(f"Parent: {_related_label(result.get('parent'))}") + print(f"Children: {_children_label(result.get('children'))}") print( f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" ) @@ -332,6 +343,24 @@ def _format_managed_agent_run(run): return "- " + " ".join(parts) +def _related_label(agent): + if not agent: + return "-" + ident = agent.get("id", "")[:8] + name = agent.get("name") or ident or "-" + status = agent.get("status") or "-" + return f"{name} [{status}] {ident}" + + +def _children_label(children): + if not children: + return "-" + labels = [_related_label(child) for child in children[:3]] + if len(children) > 3: + labels.append(f"+{len(children) - 3} more") + return ", ".join(labels) + + def _parse_timestamp(value): if not isinstance(value, str): return None @@ -1343,6 +1372,10 @@ def main(argv=None): "--created-by", help="Creator label (defaults to $USER).", ) + agent_start.add_argument( + "--parent", + help="Optional parent agent id, unique prefix, or name.", + ) agent_start.add_argument( "--stop-policy", default="until_done", @@ -1375,6 +1408,10 @@ def main(argv=None): "list", help="List durable agents in this CODEXAPI_HOME.", ) + agent_subparsers.add_parser( + "whoami", + help="Show the effective host and CODEXAPI_HOME for agents.", + ) agent_show = agent_subparsers.add_parser( "show", @@ -1731,6 +1768,7 @@ def main(argv=None): args.cwd, args.name, args.created_by, + args.parent, args.stop_policy, args.heartbeat_minutes, args.backend, @@ -1742,6 +1780,9 @@ def main(argv=None): if args.agent_command == "list": _print_managed_agent_list(list_managed_agents()) return + if args.agent_command == "whoami": + _print_managed_agent_identity() + return if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index ce2c422..99e6af1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -31,7 +31,11 @@ uninstall_cron, write_tick_wrapper, ) -from codexapi.cli import _print_managed_agent_list, _print_managed_agent_show +from codexapi.cli import ( + _print_managed_agent_identity, + _print_managed_agent_list, + _print_managed_agent_show, +) @contextmanager @@ -48,6 +52,17 @@ def test_current_hostname_prefers_override(self): self.assertEqual(current_hostname(), "stable-host") + def test_cli_whoami_shows_effective_host_and_home(self): + with _temp_home() as home: + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + _print_managed_agent_identity() + text = output.getvalue() + self.assertIn("Host: stable-host", text) + self.assertIn("Host override: stable-host", text) + self.assertIn(f"Home: {home.resolve()}", text) + def test_homes_are_isolated(self): with _temp_home() as home_a: first = start_agent("Monitor the build queue.", hostname="host-a") @@ -111,6 +126,93 @@ def fake_runner(meta, session, prompt): self.assertEqual(conversation["items"][1]["kind"], "agent") self.assertEqual(conversation["items"][1]["text"], "I saw your message.") + def test_start_agent_resolves_parent_ref(self): + with _temp_home(): + parent = start_agent( + "Parent work.", + name="parent-agent", + hostname="host-a", + ) + child = start_agent( + "Child work.", + name="child-agent", + parent_ref="parent-agent", + hostname="host-a", + ) + shown = show_agent(child["id"]) + self.assertEqual(shown["meta"]["parent_id"], parent["id"]) + self.assertEqual(shown["meta"]["created_by"], "parent-agent") + self.assertEqual(shown["parent"]["id"], parent["id"]) + + def test_managed_agent_can_create_child_with_parent_defaults(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + captured = {} + + with _temp_home(): + parent = start_agent( + "Spawn a child agent.", + name="parent-agent", + hostname="host-a", + now=start, + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + self.env = dict(env or {}) + captured["env"] = self.env + + def __call__(self, prompt): + with patch.dict(os.environ, self.env, clear=False): + child = start_agent( + "Child work.", + name="child-agent", + hostname="host-a", + ) + captured["child_id"] = child["id"] + return json.dumps( + { + "status": "Spawned child", + "continue": False, + "reply": child["id"], + } + ) + + with patch("codexapi.agents.Agent", FakeAgent): + with patch( + "codexapi.agents.utc_now", + side_effect=[start, start + timedelta(seconds=1), end], + ): + result = nudge_agent( + parent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_ID"], parent["id"]) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_NAME"], "parent-agent") + + child = show_agent(captured["child_id"]) + self.assertEqual(child["meta"]["created_by"], "parent-agent") + self.assertEqual(child["meta"]["parent_id"], parent["id"]) + self.assertEqual(child["parent"]["name"], "parent-agent") + + parent_view = show_agent(parent["id"]) + self.assertIn(captured["child_id"], parent_view["child_ids"]) + self.assertEqual(parent_view["children"][0]["name"], "child-agent") + def test_pause_then_resume(self): calls = [] From 7e45e5dd67f20f7a418d362f024ae805da331c59 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 09:54:27 +0100 Subject: [PATCH 10/31] Document durable agent usage in README --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e9472a..aa649a9 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,72 @@ If the leadbook does not exist, lead creates it with a template. ```bash codexapi lead 5 "Run the benchmark and wait for results." +codexapi lead 0 "Do a rapid triage pass and report." +``` + +### Durable agents + +`codexapi agent` is the durable long-running control plane. It stores state +under `CODEXAPI_HOME` (default `~/.codexapi`), wakes agents on cron, and lets +you inspect or message them from any shell that points at the same home. -Run without waiting between check-ins: +Start by checking the effective host/home pair and installing the scheduler: ```bash -codexapi lead 0 "Do a rapid triage pass and report." +codexapi agent whoami +codexapi agent install-cron +``` + +Start a goal-directed agent that decides for itself when it is done: + +```bash +codexapi agent start --name ci-fixer \ + "Watch CI, fix failing tests, open or update a PR, and stop when the work is done." +``` + +Start a persistent watcher that keeps running until you stop it: + +```bash +codexapi agent start --name issue-watcher \ + --stop-policy until_stopped \ + --heartbeat-minutes 30 \ + "Every wake, scan for newly assigned issues that look actionable and report or start follow-up work." ``` + +Inspect and talk to agents: + +```bash +codexapi agent list +codexapi agent show ci-fixer +codexapi agent read ci-fixer +codexapi agent send ci-fixer "Prefer the smallest safe fix." +codexapi agent wake ci-fixer +codexapi agent pause ci-fixer +codexapi agent resume ci-fixer +codexapi agent cancel ci-fixer +``` + +Create a child agent explicitly: + +```bash +codexapi agent start --name child-fix --parent ci-fixer \ + "Investigate the flaky integration test and report back." ``` +Useful environment overrides: + +```bash +CODEXAPI_HOME=/tmp/codexapi-test-home codexapi agent list +CODEXAPI_HOSTNAME=stable-host codexapi agent whoami +``` + +`CODEXAPI_HOME` isolates independent agent installations and is the right seam +for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test +wrappers report inconsistent hostnames for the same machine. + +See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling +details. + Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. From 5eef8011baa1012941e69cf331ef08e386aede4e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:18:42 +0100 Subject: [PATCH 11/31] Show immediate agent replies for send --- src/codexapi/cli.py | 25 +++++++++++++++++++++++++ tests/test_agents.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 3b8a0fc..79a8af1 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -191,6 +191,28 @@ def _print_managed_agent_identity(): print(f"Home: {codexapi_home()}") +def _send_reply_info(agent_ref, message_id): + """Return the matching run reply for one sent message, if already delivered.""" + shown = show_managed_agent(agent_ref) + for run in shown.get("recent_runs") or []: + for message in run.get("messages") or []: + if message.get("id") != message_id: + continue + info = { + "delivered": True, + "agent_status": run.get("status") or "", + "run_id": run.get("id") or "", + } + reply = run.get("reply") or "" + error = run.get("error") or "" + if reply: + info["agent_reply"] = reply + if error: + info["agent_error"] = error + return info + return None + + def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] @@ -1794,6 +1816,9 @@ def main(argv=None): if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["nudge"] = nudge_agent(args.agent_ref) + reply_info = _send_reply_info(args.agent_ref, result["id"]) + if reply_info: + result.update(reply_info) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("wake", "pause", "resume", "cancel"): diff --git a/tests/test_agents.py b/tests/test_agents.py index 99e6af1..ed1ee84 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -35,6 +35,7 @@ _print_managed_agent_identity, _print_managed_agent_list, _print_managed_agent_show, + main as cli_main, ) @@ -627,6 +628,48 @@ def fake_runner(meta, session, prompt): self.assertIn("Recent runs:", text) self.assertIn("msgs=1", text) + def test_cli_send_shows_immediate_agent_reply(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-cli-send" + return json.dumps( + { + "status": "Answered immediately", + "continue": False, + "reply": "I saw your note.", + } + ) + + output = io.StringIO() + with patch("codexapi.agents.Agent", FakeAgent): + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["nudge"]["woken"]) + self.assertTrue(payload["delivered"]) + self.assertEqual(payload["agent_status"], "Answered immediately") + self.assertEqual(payload["agent_reply"], "I saw your note.") + if __name__ == "__main__": unittest.main() From 0da89b6391088f79fd24b0f4b71c4daad2c8704a Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:20:50 +0100 Subject: [PATCH 12/31] Add agentbook inspection commands --- README.md | 4 ++++ docs/agent-v1.md | 2 ++ src/codexapi/agents.py | 12 ++++++++++++ src/codexapi/cli.py | 22 ++++++++++++++++++++++ tests/test_agents.py | 16 ++++++++++++++++ 5 files changed, 56 insertions(+) diff --git a/README.md b/README.md index aa649a9..2b57008 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Inspect and talk to agents: codexapi agent list codexapi agent show ci-fixer codexapi agent read ci-fixer +codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." codexapi agent wake ci-fixer codexapi agent pause ci-fixer @@ -204,6 +205,9 @@ CODEXAPI_HOSTNAME=stable-host codexapi agent whoami for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test wrappers report inconsistent hostnames for the same machine. +`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can +jump directly to the durable working memory file. + See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling details. diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 9bbb32a..f9b7ab0 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -642,6 +642,7 @@ V1 CLI surface: - `codexapi agent list` - `codexapi agent whoami` - `codexapi agent read` +- `codexapi agent book` - `codexapi agent show` - `codexapi agent send` - `codexapi agent wake` @@ -658,6 +659,7 @@ Expected behavior: - `whoami` prints the effective host identity and `CODEXAPI_HOME` - `read` shows recent user-visible communication derived from state and run records +- `book` prints the current `AGENTBOOK.md` text for one agent - `show` reads one agent's current snapshot and recent run history - `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files - `tick` processes due agents for the current hostname only diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 95c4fbf..3e9a6ba 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -214,6 +214,7 @@ def show_agent(agent_ref, home=None): child_map = _child_map(home) agent_dir = resolve_agent_dir(agent_ref, home) snapshot = _snapshot(agent_dir, child_map) + snapshot["agentbook_path"] = str(agent_dir / "AGENTBOOK.md") snapshot["meta"] = _read_json(agent_dir / "meta.json") snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] @@ -284,6 +285,17 @@ def read_agent(agent_ref, limit=10, home=None): } +def read_agentbook(agent_ref, home=None): + """Return the current agentbook path and text for one agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + path = agent_dir / "AGENTBOOK.md" + return { + "id": _read_json(agent_dir / "meta.json")["id"], + "path": str(path), + "text": _read_text(path), + } + + def send_agent(agent_ref, message, author=None, home=None, hostname=None, now=None): """Queue a message for an agent.""" if not isinstance(message, str) or not message.strip(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 79a8af1..b98f63b 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -21,6 +21,7 @@ list_agents as list_managed_agents, nudge_agent, read_agent as read_managed_agent, + read_agentbook, send_agent, show_agent as show_managed_agent, start_agent as start_managed_agent, @@ -226,6 +227,7 @@ def _print_managed_agent_show(result): f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" ) print(f"CWD: {meta['cwd']}") + print(f"Agentbook: {result.get('agentbook_path') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") print( "Tokens: " @@ -251,6 +253,17 @@ def _print_managed_agent_show(result): print(_format_managed_agent_run(run)) +def _print_managed_agent_book(result): + print(f"Agentbook: {result['path']}") + text = result.get("text") or "" + if text: + print() + print(text, end="" if text.endswith("\n") else "\n") + return + print() + print("(empty)") + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -1453,6 +1466,12 @@ def main(argv=None): help="Maximum number of items to show (default: 10).", ) + agent_book = agent_subparsers.add_parser( + "book", + help="Show the current agentbook for one agent.", + ) + agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_send = agent_subparsers.add_parser( "send", help="Queue a message for an agent.", @@ -1813,6 +1832,9 @@ def main(argv=None): raise SystemExit("--limit must be >= 1.") _print_managed_agent_read(read_managed_agent(args.agent_ref, args.limit)) return + if args.agent_command == "book": + _print_managed_agent_book(read_agentbook(args.agent_ref)) + return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["nudge"] = nudge_agent(args.agent_ref) diff --git a/tests/test_agents.py b/tests/test_agents.py index ed1ee84..0e47630 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -23,6 +23,7 @@ install_cron, nudge_agent, read_agent, + read_agentbook, render_cron_line, send_agent, show_agent, @@ -70,12 +71,27 @@ def test_homes_are_isolated(self): self.assertEqual(first["name"], "monitor-the-build-queue") agents_a = show_agent(first["id"]) self.assertEqual(agents_a["meta"]["hostname"], "host-a") + self.assertTrue(agents_a["agentbook_path"].endswith("/AGENTBOOK.md")) with _temp_home() as home_b: with self.assertRaises(ValueError): show_agent(first["id"]) second = start_agent("Watch CI failures.", hostname="host-b") self.assertNotEqual(first["id"], second["id"]) + def test_read_agentbook_and_cli_book(self): + with _temp_home(): + agent = start_agent("Keep notes.", hostname="host-a") + book = read_agentbook(agent["id"]) + self.assertTrue(book["path"].endswith("/AGENTBOOK.md")) + self.assertIn("# Agentbook", book["text"]) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "book", agent["id"]]) + text = output.getvalue() + self.assertIn("Agentbook:", text) + self.assertIn("# Agentbook", text) + def test_cross_host_message_waits_for_owner_tick(self): prompts = [] From 3e8d19cbc20d69f1f91c62b23f3a846872c545fd Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:27:41 +0100 Subject: [PATCH 13/31] Add agent deletion and version flag --- README.md | 2 ++ docs/agent-v1.md | 2 ++ src/codexapi/agents.py | 36 +++++++++++++++++++++ src/codexapi/cli.py | 27 ++++++++++++++++ tests/test_agents.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+) diff --git a/README.md b/README.md index 2b57008..c334a84 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Cursor agent backend. After installing, use the `codexapi` command: ```bash +codexapi --version codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run @@ -185,6 +186,7 @@ codexapi agent wake ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent cancel ci-fixer +codexapi agent delete ci-fixer ``` Create a child agent explicitly: diff --git a/docs/agent-v1.md b/docs/agent-v1.md index f9b7ab0..6dbdf0a 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -649,6 +649,7 @@ V1 CLI surface: - `codexapi agent pause` - `codexapi agent resume` - `codexapi agent cancel` +- `codexapi agent delete` - `codexapi agent tick` - `codexapi agent install-cron` @@ -662,6 +663,7 @@ Expected behavior: - `book` prints the current `AGENTBOOK.md` text for one agent - `show` reads one agent's current snapshot and recent run history - `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files +- `delete` removes one agent directory when it is safe to do so - `tick` processes due agents for the current hostname only - `install-cron` installs exactly one scheduler entry for this home on this host diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 3e9a6ba..c90ecfd 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -4,6 +4,7 @@ import os import random import shlex +import shutil import socket import string import subprocess @@ -318,6 +319,35 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def delete_agent(agent_ref, force=False, home=None): + """Delete one agent directory when it is safe to do so.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + meta = _read_json(agent_dir / "meta.json") + status = snapshot["status"] + if _run_lock_held(agent_dir / "hosts" / meta["hostname"] / "run.lock"): + raise ValueError("Cannot delete an agent while its run lock is held.") + if not force and status not in _TERMINAL_STATES: + raise ValueError( + "Refusing to delete a non-terminal agent. Cancel it first or use --force." + ) + if not force and snapshot["child_ids"]: + raise ValueError( + "Refusing to delete an agent that still has child agents. Use --force if you really want to remove it." + ) + shutil.rmtree(agent_dir) + return { + "deleted": True, + "id": snapshot["id"], + "name": snapshot["name"], + "status": status, + "path": str(agent_dir), + "forced": bool(force), + } + + def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): """Attempt an immediate wake for one locally-owned agent.""" agent_dir = resolve_agent_dir(agent_ref, home) @@ -945,6 +975,12 @@ def _write_lock_info(handle, hostname, now): handle.flush() +def _run_lock_held(path): + """Return true when the per-agent run lock is currently held.""" + with _try_lock(path) as handle: + return handle is None + + def _read_session(agent_dir): meta = _read_json(agent_dir / "meta.json") return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index b98f63b..4cb5962 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -12,11 +12,13 @@ from datetime import datetime from pathlib import Path +from . import __version__ from .agent import Agent, agent from .agents import ( codexapi_home, control_agent, current_hostname, + delete_agent as delete_managed_agent, install_cron as install_agent_cron, list_agents as list_managed_agents, nudge_agent, @@ -1299,6 +1301,11 @@ def main(argv=None): prog="codexapi", description="Run agent backends via the codexapi wrapper.", ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) subparsers = parser.add_subparsers(dest="command") run_parser = subparsers.add_parser( @@ -1490,6 +1497,17 @@ def main(argv=None): subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") + agent_delete = agent_subparsers.add_parser( + "delete", + help="Delete one durable agent and its files.", + ) + agent_delete.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_delete.add_argument( + "--force", + action="store_true", + help="Delete even when the agent is not terminal or still has children.", + ) + agent_subparsers.add_parser( "tick", help="Process due agents for the current host.", @@ -1852,6 +1870,15 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "delete": + print( + json.dumps( + delete_managed_agent(args.agent_ref, args.force), + indent=2, + sort_keys=True, + ) + ) + return if args.agent_command == "tick": print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 0e47630..5036136 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from codexapi import __version__ from codexapi.agents import ( _codex_rollout_usage, _tick_lock_path, @@ -19,6 +20,7 @@ _remove_cron_line, _upsert_cron_line, control_agent, + delete_agent, format_utc, install_cron, nudge_agent, @@ -48,6 +50,14 @@ def _temp_home(): class AgentsTests(unittest.TestCase): + def test_cli_version(self): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(["--version"]) + self.assertEqual(exc.exception.code, 0) + self.assertEqual(output.getvalue().strip(), f"codexapi {__version__}") + def test_current_hostname_prefers_override(self): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): from codexapi.agents import current_hostname @@ -92,6 +102,69 @@ def test_read_agentbook_and_cli_book(self): self.assertIn("Agentbook:", text) self.assertIn("# Agentbook", text) + def test_delete_agent_removes_done_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + result = delete_agent(agent["id"]) + self.assertTrue(result["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + + def test_delete_agent_refuses_non_terminal_without_force(self): + with _temp_home(): + agent = start_agent("Do not delete me yet.", hostname="host-a") + with self.assertRaises(ValueError): + delete_agent(agent["id"]) + result = delete_agent(agent["id"], force=True) + self.assertTrue(result["forced"]) + + def test_delete_agent_refuses_when_run_lock_held(self): + with _temp_home() as home: + agent = start_agent("Locked agent.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + lock_path = agent_dir / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + with self.assertRaises(ValueError): + delete_agent(agent["id"], force=True) + + def test_cli_delete_removes_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete-cli", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "delete", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + def test_cross_host_message_waits_for_owner_tick(self): prompts = [] From 59b405110799143a6a11e1c3fefcd1841a0114e7 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sat, 7 Mar 2026 15:27:59 +0100 Subject: [PATCH 14/31] Bump version to 0.9.0 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e82a901..664fbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.8.0" +version = "0.9.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 17c694a..0460f0f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.8.0" +__version__ = "0.9.0" From bdd4f74e0f7c73bb469976ed129382a07d430613 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 12:09:18 +0100 Subject: [PATCH 15/31] Release v0.10.0 --- README.md | 12 ++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 37 +++++++++-- src/codexapi/cli.py | 87 +++++++++++++++++++++++--- tests/test_agents.py | 130 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 253 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c334a84..8f6c948 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,12 @@ codexapi agent whoami codexapi agent install-cron ``` +If you skip `install-cron`, `codexapi agent start` warns on stderr because +background wakes will not run until the scheduler hook is installed. +When `gh` is installed and authenticated, `agent start` also captures a +background-safe `GH_TOKEN` automatically if your shell did not already export +`GH_TOKEN` or `GITHUB_TOKEN`. + Start a goal-directed agent that decides for itself when it is done: ```bash @@ -165,6 +171,9 @@ codexapi agent start --name ci-fixer \ "Watch CI, fix failing tests, open or update a PR, and stop when the work is done." ``` +Add `--wait` if you want `start` to block for the first local wake instead of +just scheduling it. + Start a persistent watcher that keeps running until you stop it: ```bash @@ -182,9 +191,12 @@ codexapi agent show ci-fixer codexapi agent read ci-fixer codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." +codexapi agent send --wait ci-fixer "Reply now if you can handle this immediately." codexapi agent wake ci-fixer +codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer +codexapi agent resume --wait ci-fixer codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` diff --git a/pyproject.toml b/pyproject.toml index 664fbc5..131bd49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.9.0" +version = "0.10.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0460f0f..b7458d2 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index c90ecfd..412b3c8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -412,6 +412,17 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } +def cron_installed(home=None, hostname=None): + """Return whether this home and host have an installed scheduler hook.""" + home = _resolve_home(home) + host = hostname or current_hostname() + tag = _cron_tag(home, host) + wrapper = home / "bin" / "agent-tick" + crontab = _read_crontab() + installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) + return installed and wrapper.exists() + + def uninstall_cron(home=None, hostname=None): """Remove the cron entry for this home and host.""" home = _resolve_home(home) @@ -915,13 +926,31 @@ def _resolve_cwd(cwd): def _capture_env(): env = {} - for key in ("PATH", "VIRTUAL_ENV"): - value = os.environ.get(key) - if value: - env[key] = value + for key, value in os.environ.items(): + if key in ("CODEXAPI_AGENT_ID", "CODEXAPI_AGENT_NAME", "CODEXAPI_AGENT_PARENT_ID"): + continue + env[key] = value + if not (env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")): + gh_token = _gh_auth_token() + if gh_token: + env["GH_TOKEN"] = gh_token return env +def _gh_auth_token(): + """Return the active gh auth token when available.""" + if shutil.which("gh") is None: + return "" + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return "" + return (result.stdout or "").strip() + + def _parent_identity(home, parent_ref): """Return the resolved parent agent id and name, if any.""" if parent_ref is not None and str(parent_ref).strip(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 4cb5962..feafe00 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -3,6 +3,7 @@ import os import re import select +import shlex import shutil import subprocess import sys @@ -17,6 +18,7 @@ from .agents import ( codexapi_home, control_agent, + cron_installed as agent_cron_installed, current_hostname, delete_agent as delete_managed_agent, install_cron as install_agent_cron, @@ -194,6 +196,39 @@ def _print_managed_agent_identity(): print(f"Home: {codexapi_home()}") +def _agent_install_cron_command(): + parts = [] + home = os.environ.get("CODEXAPI_HOME", "").strip() + host = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if home: + parts.append(f"CODEXAPI_HOME={shlex.quote(home)}") + if host: + parts.append(f"CODEXAPI_HOSTNAME={shlex.quote(host)}") + parts.extend(["codexapi", "agent", "install-cron"]) + return " ".join(parts) + + +def _warn_agent_scheduler_missing(): + try: + installed = agent_cron_installed() + except Exception as exc: + print( + "Warning: could not verify whether the codexapi agent scheduler hook is installed.", + file=sys.stderr, + ) + print(f"Reason: {exc}", file=sys.stderr) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + return + if installed: + return + print( + "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. " + "Background agent wakes will not run until you install it.", + file=sys.stderr, + ) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + + def _send_reply_info(agent_ref, message_id): """Return the matching run reply for one sent message, if already delivered.""" shown = show_managed_agent(agent_ref) @@ -1401,7 +1436,7 @@ def main(argv=None): agent_start = agent_subparsers.add_parser( "start", - help="Create a durable agent.", + help="Create a durable agent and return immediately unless --wait is set.", ) agent_start.add_argument( "prompt", @@ -1445,6 +1480,11 @@ def main(argv=None): "--flags", help="Additional raw CLI flags to pass to the backend.", ) + agent_start.add_argument( + "--wait", + action="store_true", + help="Wait for the first local wake to finish instead of just scheduling it.", + ) agent_subparsers.add_parser( "list", @@ -1481,21 +1521,32 @@ def main(argv=None): agent_send = agent_subparsers.add_parser( "send", - help="Queue a message for an agent.", + help="Queue a message for an agent and return immediately unless --wait is set.", ) agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_send.add_argument("message", help="Message to queue.") agent_send.add_argument("--author", help="Author label for the message.") + agent_send.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the message.", + ) for subcommand, help_text in ( - ("wake", "Request an extra wake for an agent."), + ("wake", "Request an extra wake for an agent and return immediately unless --wait is set."), ("pause", "Pause an agent."), - ("resume", "Resume a paused agent."), + ("resume", "Resume a paused agent and return immediately unless --wait is set."), ("cancel", "Cancel an agent."), ): subparser = agent_subparsers.add_parser(subcommand, help=help_text) subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") + if subcommand in ("wake", "resume"): + subparser.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the command.", + ) agent_delete = agent_subparsers.add_parser( "delete", @@ -1834,6 +1885,10 @@ def main(argv=None): args.yolo, args.flags, ) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(result["id"]) + _warn_agent_scheduler_missing() print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "list": @@ -1855,18 +1910,32 @@ def main(argv=None): return if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) - result["nudge"] = nudge_agent(args.agent_ref) - reply_info = _send_reply_info(args.agent_ref, result["id"]) - if reply_info: - result.update(reply_info) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref) + reply_info = _send_reply_info(args.agent_ref, result["id"]) + if reply_info: + result.update(reply_info) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("wake", "resume"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return - if args.agent_command in ("wake", "pause", "resume", "cancel"): + if args.agent_command in ("pause", "cancel"): result = control_agent( args.agent_ref, args.agent_command, args.author, ) + result["waited"] = False result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return diff --git a/tests/test_agents.py b/tests/test_agents.py index 5036136..58dadde 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,10 +1,12 @@ import io import json import os +import subprocess import sys import tempfile import unittest from contextlib import contextmanager +from contextlib import redirect_stderr from contextlib import redirect_stdout from datetime import datetime, timedelta, timezone from pathlib import Path @@ -663,6 +665,98 @@ def __call__(self, prompt): self.assertEqual(shown["state"]["reply"], "Used rollout tokens.") self.assertIn("thread-rollout", shown["session"]["rollout_path"]) + def test_start_agent_replays_start_time_env_on_later_wakes(self): + captured = {} + + with _temp_home(): + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "expected-value", + "GH_TOKEN": "ghp-test-token", + }, + clear=False, + ): + agent = start_agent( + "Use my saved environment.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + captured["env"] = dict(env or {}) + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + return json.dumps( + { + "status": "Handled with saved env", + "continue": False, + "reply": "done", + } + ) + + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "different-value", + "GH_TOKEN": "", + }, + clear=False, + ): + with patch("codexapi.agents.Agent", FakeAgent): + nudge_agent(agent["id"], hostname="host-a") + + self.assertEqual(captured["env"]["CUSTOM_AGENT_ENV"], "expected-value") + self.assertEqual(captured["env"]["GH_TOKEN"], "ghp-test-token") + + def test_start_agent_captures_gh_token_when_env_is_missing(self): + with _temp_home(): + with patch.dict( + os.environ, + {"GH_TOKEN": "", "GITHUB_TOKEN": ""}, + clear=False, + ): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["gh", "auth", "token"], + 0, + stdout="gho-from-gh-auth\n", + stderr="", + ), + ): + agent = start_agent( + "Use GitHub from cron.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "gho-from-gh-auth") + + def test_start_agent_keeps_existing_gh_token_without_calling_gh(self): + with _temp_home(): + with patch.dict(os.environ, {"GH_TOKEN": "existing-gh-token"}, clear=False): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch("codexapi.agents.subprocess.run") as run_mock: + agent = start_agent( + "Use existing GitHub token.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "existing-gh-token") + run_mock.assert_not_called() + def test_cli_managed_agent_views_show_operator_fields(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(hours=1) @@ -717,7 +811,38 @@ def fake_runner(meta, session, prompt): self.assertIn("Recent runs:", text) self.assertIn("msgs=1", text) - def test_cli_send_shows_immediate_agent_reply(self): + def test_cli_start_warns_when_cron_missing(self): + with _temp_home() as home: + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.cli.agent_cron_installed", return_value=False): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("Background agent wakes will not run", warning) + self.assertIn(str(home), warning) + self.assertIn("codexapi agent install-cron", warning) + + def test_cli_send_queues_by_default(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertNotIn("nudge", payload) + shown = show_agent(agent["id"]) + self.assertEqual(shown["unread_message_count"], 1) + self.assertEqual(shown["state"]["status"], "ready") + + def test_cli_send_wait_shows_immediate_agent_reply(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): agent = start_agent( @@ -752,8 +877,9 @@ def __call__(self, prompt): output = io.StringIO() with patch("codexapi.agents.Agent", FakeAgent): with redirect_stdout(output): - cli_main(["agent", "send", agent["id"], "status"]) + cli_main(["agent", "send", "--wait", agent["id"], "status"]) payload = json.loads(output.getvalue()) + self.assertTrue(payload["waited"]) self.assertTrue(payload["nudge"]["woken"]) self.assertTrue(payload["delivered"]) self.assertEqual(payload["agent_status"], "Answered immediately") From 9f7a2ff1c465738a921e120e41358ea6101c7bcb Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 12:57:29 +0100 Subject: [PATCH 16/31] Release v0.10.1 --- README.md | 1 + pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 45 ++++++++++++++++++++ src/codexapi/cli.py | 29 +++++++++++++ tests/test_agents.py | 90 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f6c948..96592a2 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent resume --wait ci-fixer +codexapi agent set-heartbeat ci-fixer 30 codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` diff --git a/pyproject.toml b/pyproject.toml index 131bd49..50a40e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.10.0" +version = "0.10.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b7458d2..641e09a 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.10.0" +__version__ = "0.10.1" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 412b3c8..425dcfd 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -319,6 +319,51 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No return _queue_command(agent_ref, kind, "", author, home, hostname, now) +def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None): + """Update an agent heartbeat interval.""" + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + home = _resolve_home(home) + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta_path = agent_dir / "meta.json" + state_path = agent_dir / "state.json" + meta = _read_json(meta_path) + state = _read_json(state_path) + new_minutes = int(heartbeat_minutes) + old_minutes = int(meta.get("heartbeat_minutes") or 0) + changed = old_minutes != new_minutes + if changed: + meta["heartbeat_minutes"] = new_minutes + _write_json(meta_path, meta) + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + running = _run_lock_held(run_lock_path) + rescheduled = False + if ( + not running + and state.get("status") in ("ready", "error") + and not state.get("wake_requested_at") + and state.get("next_wake_at") + ): + state["next_wake_at"] = format_utc( + now + timedelta(minutes=new_minutes) + ) + _write_json(state_path, state) + rescheduled = True + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "old_heartbeat_minutes": old_minutes, + "heartbeat_minutes": new_minutes, + "changed": changed, + "running": running, + "rescheduled": rescheduled, + "applies_after_current_run": bool(running), + "next_wake_at": state.get("next_wake_at") or "", + } + + def delete_agent(agent_ref, force=False, home=None): """Delete one agent directory when it is safe to do so.""" home = _resolve_home(home) diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index feafe00..3078cd2 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -27,6 +27,7 @@ read_agent as read_managed_agent, read_agentbook, send_agent, + set_agent_heartbeat, show_agent as show_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, @@ -1548,6 +1549,20 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) + agent_set_heartbeat = agent_subparsers.add_parser( + "set-heartbeat", + help="Update the heartbeat interval for one durable agent.", + ) + agent_set_heartbeat.add_argument( + "agent_ref", + help="Agent id, unique prefix, or name.", + ) + agent_set_heartbeat.add_argument( + "heartbeat_minutes", + type=int, + help="Heartbeat interval in minutes.", + ) + agent_delete = agent_subparsers.add_parser( "delete", help="Delete one durable agent and its files.", @@ -1939,6 +1954,20 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "set-heartbeat": + if args.heartbeat_minutes < 0: + raise SystemExit("heartbeat_minutes must be >= 0.") + print( + json.dumps( + set_agent_heartbeat( + args.agent_ref, + args.heartbeat_minutes, + ), + indent=2, + sort_keys=True, + ) + ) + return if args.agent_command == "delete": print( json.dumps( diff --git a/tests/test_agents.py b/tests/test_agents.py index 58dadde..726f7d5 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -30,6 +30,7 @@ read_agentbook, render_cron_line, send_agent, + set_agent_heartbeat, show_agent, start_agent, tick, @@ -338,6 +339,74 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["status"], "ready") self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + def test_set_agent_heartbeat_updates_meta_and_reschedules_idle_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=1) + now = start + timedelta(minutes=2) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Still watching.", + } + ), + "thread_id": "thread-heartbeat", + } + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start, + runner=fake_runner, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertTrue(result["changed"]) + self.assertTrue(result["rescheduled"]) + self.assertFalse(result["running"]) + self.assertEqual(result["heartbeat_minutes"], 10) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 10) + self.assertEqual( + shown["state"]["next_wake_at"], + format_utc(now + timedelta(minutes=10)), + ) + + def test_set_agent_heartbeat_leaves_pending_wake_time_when_wake_requested(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + now = start + timedelta(minutes=1) + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertFalse(result["rescheduled"]) + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(start)) + self.assertEqual(shown["state"]["next_wake_at"], format_utc(start)) + def test_tick_lock_is_non_blocking(self): with _temp_home() as home: start_agent("Do the thing.", hostname="host-a") @@ -885,6 +954,27 @@ def __call__(self, prompt): self.assertEqual(payload["agent_status"], "Answered immediately") self.assertEqual(payload["agent_reply"], "I saw your note.") + def test_cli_set_heartbeat_updates_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + later = start + timedelta(minutes=3) + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + output = io.StringIO() + with patch("codexapi.agents.utc_now", return_value=later): + with redirect_stdout(output): + cli_main(["agent", "set-heartbeat", agent["id"], "12"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["changed"]) + self.assertEqual(payload["heartbeat_minutes"], 12) + shown = show_agent(agent["id"]) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + if __name__ == "__main__": unittest.main() From deac7e5c8d7dbb51f17677d0a9d8a46cc4257896 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 9 Mar 2026 16:48:43 +0100 Subject: [PATCH 17/31] Add agent status command --- README.md | 10 + docs/agent-v1.md | 6 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 339 +++++++++++++++++++++++++- src/codexapi/cli.py | 62 +++++ tests/test_agents.py | 497 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 901 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 96592a2..ad9e4b1 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ Inspect and talk to agents: ```bash codexapi agent list codexapi agent show ci-fixer +codexapi agent status ci-fixer +codexapi agent status --actions ci-fixer codexapi agent read ci-fixer codexapi agent book ci-fixer codexapi agent send ci-fixer "Prefer the smallest safe fix." @@ -202,6 +204,10 @@ codexapi agent cancel ci-fixer codexapi agent delete ci-fixer ``` +`codexapi agent resume` can reopen a `done` agent. Sending to a `done` or +`canceled` agent still triggers a one-off wake on the next tick so you can get +a reply without putting the agent back into continuous heartbeat mode. + Create a child agent explicitly: ```bash @@ -222,6 +228,10 @@ wrappers report inconsistent hostnames for the same machine. `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can jump directly to the durable working memory file. +`codexapi agent status` reads the latest turn from the agent's rollout log and +shows recent commentary plus the final visible output. Pass `--actions` to +include the tool-action summary. If a wake is still in progress, it shows the +active turn so far. See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling details. diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 6dbdf0a..50740d2 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -547,9 +547,10 @@ Processing rules: - commands are applied in timestamp order - `pause` and `cancel` are applied before starting a new backend wake - `send` contributes to the next prompt and increments unread counts until - consumed + consumed; `done` and `canceled` agents still process queued messages as a + one-off wake - `wake` means run soon even if no heartbeat is due -- `resume` only changes state when the agent is paused +- `resume` reopens a `paused` or `done` agent - after successful application, the owner host records the result in state or a run record and deletes the claimed file @@ -644,6 +645,7 @@ V1 CLI surface: - `codexapi agent read` - `codexapi agent book` - `codexapi agent show` +- `codexapi agent status` - `codexapi agent send` - `codexapi agent wake` - `codexapi agent pause` diff --git a/pyproject.toml b/pyproject.toml index 50a40e3..8e25bdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.10.1" +version = "0.11.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 641e09a..54c8a7e 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.10.1" +__version__ = "0.11.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 425dcfd..247ccc0 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -227,6 +227,48 @@ def show_agent(agent_ref, home=None): return snapshot +def status_agent(agent_ref, home=None, include_actions=False): + """Return detailed transcript status for the latest agent turn.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + session = _read_session(agent_dir) + rollout_path = _resolve_rollout_path( + session.get("rollout_path"), + session.get("thread_id") or snapshot.get("thread_id") or "", + ) + result = { + "id": snapshot["id"], + "name": snapshot["name"], + "agent_status": snapshot["status"], + "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "", + "rollout_path": str(rollout_path) if rollout_path else "", + "turn_id": "", + "turn_state": "missing", + "started_at": "", + "ended_at": "", + "cwd": snapshot.get("cwd") or "", + "progress": [], + "tools": [], + "final_output": "", + "final_json": None, + } + if rollout_path is None or not rollout_path.exists(): + return result + events = _rollout_events(rollout_path) + turn = _last_rollout_turn(events, include_actions) + if turn is None: + return result + run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock" + turn_state = "complete" + if not turn["ended_at"]: + turn_state = "active" if _run_lock_held(run_lock_path) else "interrupted" + result.update(turn) + result["turn_state"] = turn_state + return result + + def read_agent(agent_ref, limit=10, home=None): """Return recent user-visible communication for an agent.""" agent_dir = resolve_agent_dir(agent_ref, home) @@ -567,15 +609,16 @@ def _tick_agent(agent_dir, now, runner): _sync_state_from_session(state, session) _write_json(session_path, session) _write_json(agent_dir / "state.json", state) - if state.get("status") not in ("ready", "error"): + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: return {"processed": bool(commands), "woken": False} if not _is_due(state, now): return {"processed": bool(commands), "woken": False} - _wake_agent(agent_dir, meta, state, session, now, commands, runner) + _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status) return {"processed": True, "woken": True} -def _wake_agent(agent_dir, meta, state, session, now, commands, runner): +def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""): prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) state["status"] = "running" state["last_wake_at"] = format_utc(now) @@ -623,7 +666,10 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): state["thread_id"] = session["thread_id"] state["wake_requested_at"] = "" state["activity"] = response["status"] - if response["continue"]: + if terminal_status: + state["status"] = terminal_status + state["next_wake_at"] = "" + elif response["continue"]: state["status"] = "ready" state["next_wake_at"] = format_utc( ended + timedelta(minutes=meta["heartbeat_minutes"]) @@ -647,13 +693,16 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner): Pushover().send(title, response["notify"]) except Exception as exc: ended = utc_now() - state["status"] = "error" + state["status"] = terminal_status or "error" state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__ state["activity"] = state["last_error"] state["wake_requested_at"] = "" - state["next_wake_at"] = format_utc( - ended + timedelta(minutes=meta["heartbeat_minutes"]) - ) + if terminal_status: + state["next_wake_at"] = "" + else: + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) _sync_state_from_session(state, session) _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) _write_json(agent_dir / "state.json", state) @@ -812,11 +861,11 @@ def _apply_commands(meta, state, session, commands, now): state["activity"] = "Paused" changed = True elif kind == "resume": - if state.get("status") == "paused": + if state.get("status") in ("paused", "done"): state["status"] = "ready" - state["wake_requested_at"] = format_utc(now) - state["activity"] = "Resumed" - changed = True + state["wake_requested_at"] = format_utc(now) + state["activity"] = "Resumed" + changed = True elif kind == "cancel": state["status"] = "canceled" state["activity"] = "Canceled" @@ -837,6 +886,8 @@ def _apply_commands(meta, state, session, commands, now): def _is_due(state, now): status = state.get("status") + if status in ("done", "canceled") and int(state.get("unread_message_count") or 0) > 0: + return True if status not in ("ready", "error"): return False if state.get("wake_requested_at"): @@ -849,6 +900,16 @@ def _is_due(state, now): return False +def _one_shot_terminal_status(state): + """Return the terminal status when a one-off message wake should run.""" + status = state.get("status") or "" + if status not in _TERMINAL_STATES: + return "" + if int(state.get("unread_message_count") or 0) < 1: + return "" + return status + + def _write_run(agent_dir, hostname, payload): runs_dir = agent_dir / "hosts" / hostname / "runs" filename = f"{payload['id']}.json" @@ -1327,8 +1388,10 @@ def _resolve_rollout_path(known_path, thread_id): """Return the rollout file for a thread, preferring the cached session path.""" if known_path: path = Path(known_path) - if path.exists() and thread_id in path.name: + if path.exists() and (not thread_id or thread_id in path.name): return path + if not thread_id: + return None root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions" if not root.exists(): return None @@ -1380,6 +1443,256 @@ def _extract_rollout_usage(path, started_at): return latest +def _rollout_events(path): + """Return parsed JSONL events from one rollout file.""" + events = [] + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + except OSError: + return [] + return events + + +def _last_rollout_turn(events, include_actions=False): + """Return the latest task_started slice from rollout events.""" + turn_events = [] + for event in events: + payload = event.get("payload") or {} + if event.get("type") == "event_msg" and payload.get("type") == "task_started": + turn_events = [event] + continue + if turn_events: + turn_events.append(event) + if not turn_events: + return None + + started = turn_events[0] + started_payload = started.get("payload") or {} + progress_events = [] + assistant_events = [] + tools = [] + tool_by_call_id = {} + ended_at = "" + task_complete_message = "" + + for event in turn_events: + payload = event.get("payload") or {} + event_type = event.get("type") + payload_type = payload.get("type") + if event_type == "event_msg": + if payload_type == "agent_message": + item = { + "text": str(payload.get("message") or "").strip(), + "phase": payload.get("phase") or "", + } + if item["text"]: + progress_events.append(item) + elif payload_type == "task_complete": + ended_at = event.get("timestamp") or "" + task_complete_message = str(payload.get("last_agent_message") or "").strip() + elif event_type == "response_item": + if payload_type == "message" and payload.get("role") == "assistant": + text = _response_message_text(payload) + if text: + assistant_events.append( + { + "text": text, + "phase": payload.get("phase") or "", + } + ) + elif include_actions and payload_type in ("function_call", "custom_tool_call"): + tool = _rollout_tool_call(payload) + if tool is None: + continue + tools.append(tool) + call_id = tool.get("call_id") or "" + if call_id: + tool_by_call_id[call_id] = tool + elif include_actions and payload_type in ("function_call_output", "custom_tool_call_output"): + tool = tool_by_call_id.get(payload.get("call_id") or "") + if tool is not None: + _apply_rollout_tool_output(tool, payload) + + visible = progress_events or assistant_events + progress = [item["text"] for item in visible] + final_output = visible[-1]["text"] if visible else task_complete_message + final_json = None + if final_output: + final_json = _parse_rollout_final_json(final_output) + if progress and progress[-1] == final_output and ( + final_json is not None or (visible[-1].get("phase") or "") == "final_answer" + ): + progress = progress[:-1] + + if include_actions: + for tool in tools: + tool["summary"] = _rollout_tool_summary(tool) + + return { + "turn_id": started_payload.get("turn_id") or "", + "started_at": started.get("timestamp") or "", + "ended_at": ended_at, + "progress": progress, + "tools": tools, + "final_output": final_output, + "final_json": final_json, + } + + +def _response_message_text(payload): + """Return the text content from one assistant response message.""" + parts = [] + for item in payload.get("content") or []: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + + +def _rollout_tool_call(payload): + """Return a compact tool-call record for one rollout item.""" + name = payload.get("name") or "" + kind = payload.get("type") or "" + call_id = payload.get("call_id") or "" + tool = { + "call_id": call_id, + "kind": kind, + "name": name, + "command": "", + "files": [], + "exit_code": None, + "output": "", + "summary": "", + } + if kind == "function_call": + arguments = _parse_rollout_json(payload.get("arguments")) + if isinstance(arguments, dict): + tool["command"] = str(arguments.get("cmd") or "").strip() + elif kind == "custom_tool_call" and name == "apply_patch": + tool["files"] = _patch_targets(payload.get("input") or "") + return tool + + +def _apply_rollout_tool_output(tool, payload): + """Fold tool output into a compact rollout tool record.""" + text, exit_code = _tool_output_details(payload.get("output")) + if exit_code is not None: + tool["exit_code"] = exit_code + tool["output"] = _snippet(text.strip(), 400) if text else "" + if tool["name"] == "apply_patch" and not tool["files"]: + tool["files"] = _updated_files(text) + + +def _parse_rollout_json(value): + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value.strip(): + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _parse_rollout_final_json(text): + """Return the normalized final agent JSON when the text matches the contract.""" + try: + return _parse_agent_response(text) + except ValueError: + return None + + +def _tool_output_details(output): + """Return normalized output text and exit code from a rollout tool result.""" + text = str(output or "") + payload = _parse_rollout_json(text) + exit_code = None + if isinstance(payload, dict): + metadata = payload.get("metadata") or {} + exit_code = _usage_int(metadata.get("exit_code")) + text = str(payload.get("output") or "") + raw = text + body = raw + if "\nOutput:\n" in raw: + body = raw.split("\nOutput:\n", 1)[1] + elif raw.startswith("Output:\n"): + body = raw.split("Output:\n", 1)[1] + for line in raw.splitlines(): + if not line.startswith("Process exited with code "): + continue + tail = line.rsplit(" ", 1)[-1].strip() + if tail.startswith("-"): + tail = tail[1:] + if tail.isdigit(): + exit_code = int(line.rsplit(" ", 1)[-1].strip()) + break + return body.strip(), exit_code + + +def _rollout_tool_summary(tool): + """Return one readable summary line for a tool action.""" + name = tool.get("name") or "" + exit_code = tool.get("exit_code") + suffix = "" + if exit_code is not None: + suffix = f" (exit {exit_code})" + if name == "exec_command": + command = _single_line(_snippet(tool.get("command") or "", 140)) + if command: + return f"Running command: {command}{suffix}" + return f"Running command{suffix}" + if name == "apply_patch": + files = tool.get("files") or [] + if files: + label = ", ".join(files[:3]) + if len(files) > 3: + label += ", ..." + return f"Editing files: {label}{suffix}" + return f"Editing files{suffix}" + if name: + return f"{name}{suffix}" + return f"tool{suffix}" + + +def _patch_targets(text): + """Return patch target files from an apply_patch input.""" + files = [] + for line in str(text or "").splitlines(): + for prefix in ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "): + if not line.startswith(prefix): + continue + target = line[len(prefix) :].strip() + if target and target not in files: + files.append(target) + return files + + +def _updated_files(text): + """Return file paths mentioned in apply_patch output.""" + files = [] + for line in str(text or "").splitlines(): + line = line.strip() + if not line or line == "Success. Updated the following files:": + continue + if line.startswith(("M ", "A ", "D ")): + target = line[2:].strip() + if target and target not in files: + files.append(target) + return files + + def _cron_tag(home, hostname): key = sha1(str(home).encode("utf-8")).hexdigest()[:12] return f"codexapi-agent::{hostname}::{key}" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 3078cd2..60812ee 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -29,6 +29,7 @@ send_agent, set_agent_heartbeat, show_agent as show_managed_agent, + status_agent as status_managed_agent, start_agent as start_managed_agent, tick as tick_managed_agents, uninstall_cron as uninstall_agent_cron, @@ -190,6 +191,48 @@ def _print_managed_agent_read(result): print() +def _print_managed_agent_status(result, include_actions=False): + print(f"{result['name']} [{result['agent_status'] or '-'}]") + print(f"ID: {result['id']}") + print(f"Thread: {result.get('thread_id') or '-'}") + print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]") + print(f"Started: {result.get('started_at') or '-'}") + print(f"Ended: {result.get('ended_at') or '-'}") + print(f"CWD: {result.get('cwd') or '-'}") + print(f"Rollout: {result.get('rollout_path') or '-'}") + progress = result.get("progress") or [] + print("Progress:") + if not progress: + print("- none") + else: + for item in progress: + print(f"- {_single_line(item)}") + if include_actions: + tools = result.get("tools") or [] + print("Actions:") + if not tools: + print("- none") + else: + for tool in tools: + print(f"- {tool.get('summary') or tool.get('name') or 'tool'}") + if tool.get("output"): + print(f" Output: {_single_line(tool['output'])}") + final_json = result.get("final_json") + if final_json is not None: + print("Final fields:") + print(f"Status: {final_json.get('status') or '-'}") + print(f"Continue: {str(bool(final_json.get('continue'))).lower()}") + print(f"Reply: {final_json.get('reply') or '-'}") + print(f"Notify: {final_json.get('notify') or '-'}") + return + final_output = result.get("final_output") or "" + print("Final output:") + if final_output: + print(final_output) + else: + print("-") + + def _print_managed_agent_identity(): override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() print(f"Host: {current_hostname()}") @@ -1502,6 +1545,19 @@ def main(argv=None): ) agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_status = agent_subparsers.add_parser( + "status", + help="Show the latest rollout turn for one durable agent.", + ) + agent_status.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_status.add_argument( + "--actions", + "--with-actions", + action="store_true", + dest="actions", + help="Include verbose tool actions from the latest turn.", + ) + agent_read = agent_subparsers.add_parser( "read", help="Read recent visible communication for one agent.", @@ -1915,6 +1971,12 @@ def main(argv=None): if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return + if args.agent_command == "status": + _print_managed_agent_status( + status_managed_agent(args.agent_ref, include_actions=args.actions), + include_actions=args.actions, + ) + return if args.agent_command == "read": if args.limit < 1: raise SystemExit("--limit must be >= 1.") diff --git a/tests/test_agents.py b/tests/test_agents.py index 726f7d5..f5f01be 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -33,6 +33,7 @@ set_agent_heartbeat, show_agent, start_agent, + status_agent, tick, uninstall_cron, write_tick_wrapper, @@ -52,6 +53,26 @@ def _temp_home(): yield Path(tmpdir) +def _write_rollout(path, events): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + + +def _set_rollout_session(home, agent_id, hostname, thread_id, rollout_path): + session_path = home / "agents" / agent_id / "hosts" / hostname / "session.json" + state_path = home / "agents" / agent_id / "state.json" + session = json.loads(session_path.read_text(encoding="utf-8")) + state = json.loads(state_path.read_text(encoding="utf-8")) + session["thread_id"] = thread_id + session["rollout_path"] = str(rollout_path) + state["thread_id"] = thread_id + session_path.write_text(json.dumps(session, indent=2, sort_keys=True) + "\n", encoding="utf-8") + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + class AgentsTests(unittest.TestCase): def test_cli_version(self): output = io.StringIO() @@ -339,6 +360,119 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["status"], "ready") self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + def test_resume_done_agent_reopens_it(self): + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def resume_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Back on it", + "continue": True, + "reply": "Reopened.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "done") + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=resume_runner) + self.assertEqual(resumed["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["reply"], "Reopened.") + + def test_send_wakes_done_agent_once_without_reopening(self): + prompts = [] + + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + def test_send_wakes_canceled_agent_once_without_reopening(self): + prompts = [] + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-canceled", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + control_agent(agent["id"], "cancel", hostname="host-b") + tick(hostname="host-a", runner=reply_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "canceled") + + send_agent(agent["id"], "status", author="mark", hostname="host-b") + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[-1]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "canceled") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + def test_set_agent_heartbeat_updates_meta_and_reschedules_idle_agent(self): start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) end = start + timedelta(minutes=1) @@ -975,6 +1109,369 @@ def test_cli_set_heartbeat_updates_agent(self): shown = show_agent(agent["id"]) self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + def test_status_agent_returns_latest_completed_turn(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-status.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T13:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-old"}, + }, + { + "timestamp": "2026-03-09T13:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Old turn.", + }, + }, + { + "timestamp": "2026-03-09T13:00:02Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-old", + "last_agent_message": "{\"status\":\"Old\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T13:10:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-new"}, + }, + { + "timestamp": "2026-03-09T13:10:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the repository state.", + }, + }, + { + "timestamp": "2026-03-09T13:10:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cmd", + }, + }, + { + "timestamp": "2026-03-09T13:10:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cmd", + "output": "Chunk ID: 123456\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Updating the agent notes now.", + }, + }, + { + "timestamp": "2026-03-09T13:10:05Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "status": "completed", + "call_id": "call-patch", + "input": "*** Begin Patch\n*** Update File: /tmp/AGENTBOOK.md\n@@\n-old\n+new\n*** End Patch\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:06Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call-patch", + "output": json.dumps( + { + "output": "Success. Updated the following files:\nM /tmp/AGENTBOOK.md\n", + "metadata": {"exit_code": 0, "duration_seconds": 0.1}, + } + ), + }, + }, + { + "timestamp": "2026-03-09T13:10:07Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + { + "timestamp": "2026-03-09T13:10:08Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-new", + "last_agent_message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-status", rollout) + + result = status_agent(agent["id"], include_actions=True) + self.assertEqual(result["turn_id"], "turn-new") + self.assertEqual(result["turn_state"], "complete") + self.assertEqual(result["started_at"], "2026-03-09T13:10:00Z") + self.assertEqual(result["ended_at"], "2026-03-09T13:10:08Z") + self.assertEqual( + result["progress"], + [ + "Checking the repository state.", + "Updating the agent notes now.", + ], + ) + self.assertEqual(result["final_json"]["status"], "Ready to merge") + self.assertEqual(result["final_json"]["reply"], "Looks good.") + self.assertEqual(len(result["tools"]), 2) + self.assertEqual(result["tools"][0]["name"], "exec_command") + self.assertEqual(result["tools"][0]["command"], "git status --short") + self.assertEqual(result["tools"][0]["exit_code"], 0) + self.assertEqual(result["tools"][0]["output"], "M README.md") + self.assertEqual(result["tools"][1]["name"], "apply_patch") + self.assertEqual(result["tools"][1]["files"], ["/tmp/AGENTBOOK.md"]) + + def test_status_agent_returns_missing_when_no_rollout_is_known(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + result = status_agent(agent["id"]) + self.assertEqual(result["turn_state"], "missing") + self.assertEqual(result["rollout_path"], "") + self.assertEqual(result["progress"], []) + + def test_status_agent_returns_active_turn_when_run_lock_is_held(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-active.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-active"}, + }, + { + "timestamp": "2026-03-09T14:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the latest CI run now.", + }, + }, + { + "timestamp": "2026-03-09T14:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "gh pr checks 123"}), + "call_id": "call-live", + }, + }, + { + "timestamp": "2026-03-09T14:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-live", + "output": "Chunk ID: 654321\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nci / in_progress\n", + }, + }, + { + "timestamp": "2026-03-09T14:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "The required checks are still running.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-active", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + result = status_agent(agent["id"]) + + self.assertEqual(result["turn_id"], "turn-active") + self.assertEqual(result["turn_state"], "active") + self.assertEqual(result["ended_at"], "") + self.assertEqual(result["final_output"], "The required checks are still running.") + self.assertIsNone(result["final_json"]) + self.assertEqual( + result["progress"], + [ + "Checking the latest CI run now.", + "The required checks are still running.", + ], + ) + + def test_cli_status_shows_latest_turn_details(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T15:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli"}, + }, + { + "timestamp": "2026-03-09T15:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T15:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli", + }, + }, + { + "timestamp": "2026-03-09T15:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli", + "output": "Chunk ID: 111111\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T15:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T15:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", agent["id"]]) + text = output.getvalue() + self.assertIn("Turn: turn-cli [complete]", text) + self.assertIn("Progress:", text) + self.assertIn("Inspecting the latest rollout details.", text) + self.assertIn("Final fields:", text) + self.assertIn("Status: Handled", text) + self.assertNotIn("Final output:", text) + self.assertNotIn("Actions:", text) + + def test_cli_status_with_actions_shows_tool_summaries(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli-actions.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T16:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli-actions"}, + }, + { + "timestamp": "2026-03-09T16:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T16:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli-actions", + }, + }, + { + "timestamp": "2026-03-09T16:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli-actions", + "output": "Chunk ID: 222222\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T16:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T16:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli-actions", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli-actions", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", "--actions", agent["id"]]) + text = output.getvalue() + self.assertIn("Actions:", text) + self.assertIn("Running command: git status --short (exit 0)", text) + if __name__ == "__main__": unittest.main() From 750c5e68da793eae4ab5122800a17bcc2465ee23 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 11 Mar 2026 11:49:58 +0100 Subject: [PATCH 18/31] Improve durable agent runtime and bump version to 0.12.0 --- README.md | 4 ++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 101 +++++++++++++++++++++++++++++++++++---- src/codexapi/cli.py | 38 +++++++++++++-- tests/test_agents.py | 46 +++++++++++++++++- 6 files changed, 176 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ad9e4b1..b8663aa 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ codexapi agent whoami codexapi agent install-cron ``` +`codexapi agent install-cron` installs one background scheduler hook for this +`CODEXAPI_HOME`. The wrapper runs `codexapi tick`, which drives the durable +agent wake scan. + If you skip `install-cron`, `codexapi agent start` warns on stderr because background wakes will not run until the scheduler hook is installed. When `gh` is installed and authenticated, `agent start` also captures a diff --git a/pyproject.toml b/pyproject.toml index 8e25bdc..9c7735b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.11.0" +version = "0.12.0" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 54c8a7e..b67863c 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.11.0" +__version__ = "0.12.0" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 247ccc0..636992d 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -33,8 +33,9 @@ "on an ongoing job. Be independent and practical. Manage work and follow " "through. Use codexapi task or codexapi science when you want a separate " "coding worker. If you need the user's attention, put a short message in the " - "reply field. If something is urgent and should send Pushover, put it in the " - "notify field. Respond with JSON only." + "reply field. Put a short first-person turn summary in the update field. If " + "something is urgent and should send Pushover, put it in the notify field. " + "Respond with JSON only." ) _AGENT_JSON = ( "Respond with JSON only (no markdown/backticks/extra text).\n" @@ -42,6 +43,7 @@ " status: string (one line)\n" " continue: boolean\n" " reply: string (optional)\n" + " update: string (recommended; short first-person summary of this turn)\n" " notify: string (optional)\n" ) _COMMAND_KINDS = {"send", "wake", "pause", "resume", "cancel"} @@ -181,6 +183,7 @@ def start_agent( "last_error": "", "activity": "Created", "reply": "", + "update": "", } _write_json(agent_dir / "meta.json", meta) @@ -435,8 +438,9 @@ def delete_agent(agent_ref, force=False, home=None): } -def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): - """Attempt an immediate wake for one locally-owned agent.""" +def run_agent(agent_ref, home=None, hostname=None, now=None, runner=None): + """Run one agent synchronously when it is locally owned.""" + home = _resolve_home(home) agent_dir = resolve_agent_dir(agent_ref, home) meta = _read_json(agent_dir / "meta.json") host = hostname or current_hostname() @@ -451,6 +455,26 @@ def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None): } +def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None, wait=True): + """Attempt an immediate wake for one locally-owned agent.""" + home = _resolve_home(home) + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + if runner is not None or wait: + return run_agent(agent_ref, home, host, now, runner) + _spawn_agent_process(meta["id"], home, host) + return { + "ran": True, + "reason": "local", + "processed": 1, + "woken": 1, + "spawned": True, + } + + def tick(home=None, hostname=None, now=None, runner=None): """Process due agents for the current host.""" home = _resolve_home(home) @@ -467,11 +491,19 @@ def tick(home=None, hostname=None, now=None, runner=None): for agent in list_agents(home): if agent["hostname"] != host: continue - outcome = _tick_agent(_agent_dir(home, agent["id"]), now, runner) - if outcome["processed"]: - processed += 1 - if outcome["woken"]: - woken += 1 + agent_dir = _agent_dir(home, agent["id"]) + if runner is not None: + outcome = _tick_agent(agent_dir, now, runner) + if outcome["processed"]: + processed += 1 + if outcome["woken"]: + woken += 1 + continue + if not _agent_needs_tick(agent_dir, now): + continue + _spawn_agent_process(agent["id"], home, host) + processed += 1 + woken += 1 return {"ran": True, "hostname": host, "processed": processed, "woken": woken} @@ -568,7 +600,7 @@ def write_tick_wrapper(home=None, python_executable=None, path_value=None, hostn f"export CODEXAPI_HOME={shlex.quote(str(home))}", f"export CODEXAPI_HOSTNAME={shlex.quote(str(hostname))}", f"export PATH={shlex.quote(path_value)}", - f"exec {shlex.quote(str(python_executable))} -m codexapi agent tick", + f"exec {shlex.quote(str(python_executable))} -m codexapi tick", ] _write_text(wrapper, "\n".join(lines) + "\n") wrapper.chmod(0o755) @@ -618,6 +650,35 @@ def _tick_agent(agent_dir, now, runner): return {"processed": True, "woken": True} +def _agent_needs_tick(agent_dir, now): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + if _run_lock_held(run_lock_path): + return False + if _has_new_commands(agent_dir): + return True + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: + return False + return _is_due(state, now) + + +def _spawn_agent_process(agent_id, home, hostname): + env = dict(os.environ) + env["CODEXAPI_HOME"] = str(home) + env["CODEXAPI_HOSTNAME"] = str(hostname) + subprocess.Popen( + [sys.executable, "-m", "codexapi", "agent", "run", agent_id], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + close_fds=True, + start_new_session=True, + ) + + def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""): prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) state["status"] = "running" @@ -636,6 +697,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal "messages": [], "status": "", "reply": "", + "update": "", "notify": "", "error": "", "continue": True, @@ -661,6 +723,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal usage = _normalize_usage(outcome.get("usage")) _add_usage(meta, state, usage, ended) state["reply"] = response["reply"] + state["update"] = response["update"] state["last_success_at"] = format_utc(ended) state["last_error"] = "" state["thread_id"] = session["thread_id"] @@ -683,6 +746,7 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal run["ended_at"] = format_utc(ended) run["status"] = response["status"] run["reply"] = response["reply"] + run["update"] = response["update"] run["notify"] = response["notify"] run["continue"] = bool(response["continue"]) run["usage"] = usage @@ -757,6 +821,7 @@ def _parse_agent_response(output): status = payload.get("status") cont = payload.get("continue") reply = payload.get("reply") + update = payload.get("update") notify = payload.get("notify") if not isinstance(status, str) or not status.strip(): raise ValueError("Agent response missing string 'status'.") @@ -764,16 +829,21 @@ def _parse_agent_response(output): raise ValueError("Agent response missing boolean 'continue'.") if reply is None: reply = "" + if update is None: + update = reply or status or "" if notify is None: notify = "" if not isinstance(reply, str): raise ValueError("Agent response missing string 'reply'.") + if not isinstance(update, str): + raise ValueError("Agent response missing string 'update'.") if not isinstance(notify, str): raise ValueError("Agent response missing string 'notify'.") return { "status": _single_line(status), "continue": cont, "reply": reply.strip(), + "update": update.strip(), "notify": notify.strip(), } @@ -986,6 +1056,7 @@ def _snapshot(agent_dir, child_map=None): "last_error": state.get("last_error") or "", "activity": state.get("activity") or "", "reply": state.get("reply") or "", + "update": state.get("update") or "", } @@ -1323,6 +1394,16 @@ def _queued_send_commands(agent_dir): return queued +def _has_new_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return False + for path in new_dir.iterdir(): + if path.is_file() and path.suffix == ".json": + return True + return False + + def _child_map(home): """Return parent_id -> [child ids] for this home.""" root = _resolve_home(home) / "agents" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 60812ee..45415e9 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -26,6 +26,7 @@ nudge_agent, read_agent as read_managed_agent, read_agentbook, + run_agent as run_managed_agent, send_agent, set_agent_heartbeat, show_agent as show_managed_agent, @@ -223,6 +224,7 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Status: {final_json.get('status') or '-'}") print(f"Continue: {str(bool(final_json.get('continue'))).lower()}") print(f"Reply: {final_json.get('reply') or '-'}") + print(f"Update: {final_json.get('update') or '-'}") print(f"Notify: {final_json.get('notify') or '-'}") return final_output = result.get("final_output") or "" @@ -273,6 +275,10 @@ def _warn_agent_scheduler_missing(): print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) +def _system_tick(): + return {"agents": tick_managed_agents()} + + def _send_reply_info(agent_ref, message_id): """Return the matching run reply for one sent message, if already delivered.""" shown = show_managed_agent(agent_ref) @@ -286,9 +292,12 @@ def _send_reply_info(agent_ref, message_id): "run_id": run.get("id") or "", } reply = run.get("reply") or "" + update = run.get("update") or "" error = run.get("error") or "" if reply: info["agent_reply"] = reply + if update: + info["agent_update"] = update if error: info["agent_error"] = error return info @@ -319,6 +328,7 @@ def _print_managed_agent_show(result): ) print(f"Activity: {_state_text(state.get('activity'))}") print(f"Reply: {_state_text(state.get('reply'))}") + print(f"Update: {_state_text(state.get('update'))}") print(f"Last error: {_state_text(state.get('last_error'))}") print(f"Last wake: {_state_time(state.get('last_wake_at'))}") print(f"Last success: {_state_time(state.get('last_success_at'))}") @@ -448,11 +458,14 @@ def _format_managed_agent_run(run): tokens = _format_token_total(usage.get("total_tokens")) status = run.get("error") or run.get("status") or "-" reply = run.get("reply") or "" + update = run.get("update") or "" message_count = len(run.get("messages") or []) parts = [started, reason, tokens] if message_count: parts.append(f"msgs={message_count}") summary = _truncate_head(_single_line(status), 60) + if update: + summary = _truncate_head(f"{summary} | {_single_line(update)}", 100) if reply: summary = _truncate_head(f"{summary} | {_single_line(reply)}", 100) parts.append(summary) @@ -1539,6 +1552,12 @@ def main(argv=None): help="Show the effective host and CODEXAPI_HOME for agents.", ) + agent_run = agent_subparsers.add_parser( + "run", + help=argparse.SUPPRESS, + ) + agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) + agent_show = agent_subparsers.add_parser( "show", help="Show one durable agent.", @@ -1643,6 +1662,11 @@ def main(argv=None): help="Remove the cron entry for this CODEXAPI_HOME.", ) + subparsers.add_parser( + "tick", + help="Run one full background tick.", + ) + task_parser = subparsers.add_parser( "task", help="Run a task with verification retries.", @@ -1958,7 +1982,7 @@ def main(argv=None): ) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(result["id"]) + result["nudge"] = nudge_agent(result["id"], wait=True) _warn_agent_scheduler_missing() print(json.dumps(result, indent=2, sort_keys=True)) return @@ -1968,6 +1992,9 @@ def main(argv=None): if args.agent_command == "whoami": _print_managed_agent_identity() return + if args.agent_command == "run": + print(json.dumps(run_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + return if args.agent_command == "show": _print_managed_agent_show(show_managed_agent(args.agent_ref)) return @@ -1989,7 +2016,7 @@ def main(argv=None): result = send_agent(args.agent_ref, args.message, args.author) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) reply_info = _send_reply_info(args.agent_ref, result["id"]) if reply_info: result.update(reply_info) @@ -2003,7 +2030,7 @@ def main(argv=None): ) result["waited"] = bool(args.wait) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("pause", "cancel"): @@ -2013,7 +2040,7 @@ def main(argv=None): args.author, ) result["waited"] = False - result["nudge"] = nudge_agent(args.agent_ref) + result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command == "set-heartbeat": @@ -2048,6 +2075,9 @@ def main(argv=None): if args.agent_command == "uninstall-cron": print(json.dumps(uninstall_agent_cron(), indent=2, sort_keys=True)) return + if args.command == "tick": + print(json.dumps(_system_tick(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return diff --git a/tests/test_agents.py b/tests/test_agents.py index f5f01be..fbf2c27 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -566,7 +566,7 @@ def test_write_tick_wrapper_pins_home_and_python(self): self.assertIn("export CODEXAPI_HOSTNAME=stable-host", text) self.assertIn("export PATH=", text) self.assertIn("/tmp/venv/bin:/usr/bin", text) - self.assertIn("exec /tmp/venv/bin/python -m codexapi agent tick", text) + self.assertIn("exec /tmp/venv/bin/python -m codexapi tick", text) def test_upsert_cron_line_keeps_different_homes_separate(self): line_a = render_cron_line(home="/tmp/home-a", hostname="host-a") @@ -711,6 +711,50 @@ def fake_runner(meta, session, prompt): self.assertEqual(shown["state"]["reply"], "Message handled.") self.assertEqual(shown["unread_message_count"], 0) + def test_nudge_agent_can_spawn_async_process(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = nudge_agent( + agent["id"], + home=home, + hostname="host-a", + wait=False, + ) + self.assertTrue(result["ran"]) + self.assertTrue(result["spawned"]) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0][-2:], ["run", agent["id"]]) + + def test_tick_spawns_one_process_per_due_local_agent(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + first = start_agent("First job.", hostname="host-a") + second = start_agent("Second job.", hostname="host-a") + start_agent("Remote job.", hostname="host-b") + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = tick(home=home, hostname="host-a") + self.assertTrue(result["ran"]) + self.assertEqual(result["processed"], 2) + self.assertEqual(result["woken"], 2) + self.assertEqual(len(calls), 2) + spawned = sorted(call[0][-1] for call in calls) + self.assertEqual(spawned, sorted([first["id"], second["id"]])) + def test_codex_rollout_usage_uses_latest_event_after_start(self): started = datetime(2026, 3, 6, 8, 0, 5, tzinfo=timezone.utc) with _temp_home() as home: From d8fb58af079755b9dd983f8e422fbfdf329bdf45 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 13 Mar 2026 09:21:05 +0100 Subject: [PATCH 19/31] Add stale wake recovery for agents --- README.md | 6 ++ src/codexapi/agents.py | 182 ++++++++++++++++++++++++++++++++++++++++- src/codexapi/cli.py | 39 +++++++++ tests/test_agents.py | 159 ++++++++++++++++++++++++++++++++++- 4 files changed, 383 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8663aa..22aa230 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,8 @@ codexapi agent wake --wait ci-fixer codexapi agent pause ci-fixer codexapi agent resume ci-fixer codexapi agent resume --wait ci-fixer +codexapi agent recover ci-fixer +codexapi agent recover --wait ci-fixer codexapi agent set-heartbeat ci-fixer 30 codexapi agent cancel ci-fixer codexapi agent delete ci-fixer @@ -211,6 +213,10 @@ codexapi agent delete ci-fixer `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or `canceled` agent still triggers a one-off wake on the next tick so you can get a reply without putting the agent back into continuous heartbeat mode. +`codexapi agent recover` is for a different failure mode: a local wake that is +still marked `running` but has stopped making rollout progress. `agent list`, +`agent show`, and `agent status` now surface stale running wakes, and `recover` +terminates the stuck local wake, marks it recoverable, and queues a fresh one. Create a child agent explicitly: diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 636992d..e3dbde9 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,6 +3,7 @@ import json import os import random +import signal import shlex import shutil import socket @@ -10,6 +11,7 @@ import subprocess import sys import tempfile +import time import uuid from contextlib import contextmanager from datetime import datetime, timedelta, timezone @@ -50,6 +52,11 @@ _STOP_POLICIES = {"until_done", "until_stopped"} _TERMINAL_STATES = {"done", "canceled"} _ACTIVE_STATES = {"ready", "error", "running", "paused"} +_STALE_MIN_SECONDS = 30 * 60 +_STALE_HEARTBEAT_MULTIPLIER = 3 +_RECOVER_TERM_TIMEOUT = 3.0 +_RECOVER_KILL_TIMEOUT = 3.0 +_RECOVER_POLL_INTERVAL = 0.1 def codexapi_home(): @@ -223,6 +230,11 @@ def show_agent(agent_ref, home=None): snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] + snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"] + snapshot["state"]["last_event_at"] = snapshot["last_event_at"] + snapshot["state"]["stale"] = snapshot["stale"] + snapshot["state"]["stale_after_seconds"] = snapshot["stale_after_seconds"] + snapshot["state"]["stale_for_seconds"] = snapshot["stale_for_seconds"] snapshot["session"] = _read_session(agent_dir) snapshot["recent_runs"] = _recent_runs(agent_dir, 5) snapshot["parent"] = _agent_brief(home, snapshot["parent_id"], child_map) @@ -256,6 +268,11 @@ def status_agent(agent_ref, home=None, include_actions=False): "tools": [], "final_output": "", "final_json": None, + "run_lock_held": snapshot["run_lock_held"], + "last_event_at": snapshot["last_event_at"], + "stale": snapshot["stale"], + "stale_after_seconds": snapshot["stale_after_seconds"], + "stale_for_seconds": snapshot["stale_for_seconds"], } if rollout_path is None or not rollout_path.exists(): return result @@ -266,9 +283,13 @@ def status_agent(agent_ref, home=None, include_actions=False): run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock" turn_state = "complete" if not turn["ended_at"]: - turn_state = "active" if _run_lock_held(run_lock_path) else "interrupted" + if _run_lock_held(run_lock_path): + turn_state = "stale" if snapshot["stale"] else "active" + else: + turn_state = "interrupted" result.update(turn) result["turn_state"] = turn_state + result["last_event_at"] = turn.get("last_event_at") or result["last_event_at"] return result @@ -409,6 +430,56 @@ def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None): } +def recover_agent(agent_ref, home=None, hostname=None, now=None): + """Recover one local running agent by clearing a stuck wake and requeueing it.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + if meta["hostname"] != host: + raise ValueError("Cannot recover a remote agent from this host.") + state_path = agent_dir / "state.json" + state = _read_json(state_path) + if (state.get("status") or "") != "running": + raise ValueError("Recover only applies to agents in the running state.") + session_path = agent_dir / "hosts" / meta["hostname"] / "session.json" + session = _read_json(session_path) + runtime = _agent_runtime(agent_dir, meta, state, session, now) + signal_result = { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + if runtime["run_lock_held"]: + signal_result = _recover_run_lock( + agent_dir / "hosts" / meta["hostname"] / "run.lock" + ) + state = _read_json(state_path) + session = _read_json(session_path) + state["status"] = "error" + state["last_error"] = "Recovered stuck wake." + state["activity"] = state["last_error"] + state["wake_requested_at"] = format_utc(now) + _sync_state_from_session(state, session) + _write_json(state_path, state) + return { + "id": meta["id"], + "name": meta["name"], + "status": state["status"], + "recovered": True, + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], + "wake_requested_at": state["wake_requested_at"], + "last_error": state["last_error"], + "signal": signal_result, + } + + def delete_agent(agent_ref, force=False, home=None): """Delete one agent directory when it is safe to do so.""" home = _resolve_home(home) @@ -1024,6 +1095,8 @@ def _queue_command(agent_ref, kind, body, author, home, hostname, now): def _snapshot(agent_dir, child_map=None): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + runtime = _agent_runtime(agent_dir, meta, state, session) if child_map is None: child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) else: @@ -1057,9 +1130,50 @@ def _snapshot(agent_dir, child_map=None): "activity": state.get("activity") or "", "reply": state.get("reply") or "", "update": state.get("update") or "", + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], } +def _agent_runtime(agent_dir, meta, state, session=None, now=None): + """Return live wake health for one agent.""" + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + run_lock_held = _run_lock_held(run_lock_path) + stale_after_seconds = _stale_after_seconds(meta.get("heartbeat_minutes") or 0) + info = { + "run_lock_held": run_lock_held, + "last_event_at": "", + "stale": False, + "stale_after_seconds": stale_after_seconds, + "stale_for_seconds": 0, + } + if (state.get("status") or "") != "running" and not run_lock_held: + return info + now = now or utc_now() + session = session or _read_session(agent_dir) + thread_id = session.get("thread_id") or state.get("thread_id") or "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is not None and rollout_path.exists(): + turn = _last_rollout_turn(_rollout_events(rollout_path)) + if turn is not None: + info["last_event_at"] = turn.get("last_event_at") or turn.get("started_at") or "" + last_progress_at = parse_utc(info["last_event_at"]) or parse_utc(state.get("last_wake_at")) + if last_progress_at is None: + return info + idle = max(0, int((now - last_progress_at).total_seconds())) + info["stale_for_seconds"] = idle + info["stale"] = run_lock_held and idle >= stale_after_seconds + return info + + +def _stale_after_seconds(heartbeat_minutes): + minutes = int(heartbeat_minutes or 0) + return max(_STALE_MIN_SECONDS, minutes * 60 * _STALE_HEARTBEAT_MULTIPLIER) + + def _choose_name(home, prompt, requested): base = _slugify(requested or prompt) if not base: @@ -1187,6 +1301,69 @@ def _run_lock_held(path): return handle is None +def _lock_info(path): + try: + return _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _signal_run_process(pid, sig): + """Signal the wake process group when possible, else just the pid.""" + pgid = None + try: + pgid = os.getpgid(pid) + except OSError: + pgid = None + if pgid is not None: + os.killpg(pgid, sig) + else: + os.kill(pid, sig) + return pgid + + +def _wait_for_lock_release(path, timeout_seconds): + deadline = time.monotonic() + max(0.0, float(timeout_seconds)) + while time.monotonic() < deadline: + if not _run_lock_held(path): + return True + time.sleep(_RECOVER_POLL_INTERVAL) + return not _run_lock_held(path) + + +def _recover_run_lock(path): + """Terminate the current lock holder and wait for the wake lock to clear.""" + if not _run_lock_held(path): + return { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + info = _lock_info(path) + pid = _usage_int(info.get("pid")) + if pid is None: + raise ValueError("Run lock is held but has no recorded pid.") + result = { + "pid": pid, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + try: + result["pgid"] = _signal_run_process(pid, signal.SIGTERM) + result["sent_sigterm"] = True + except ProcessLookupError: + result["pgid"] = None + if _wait_for_lock_release(path, _RECOVER_TERM_TIMEOUT): + return result + result["pgid"] = _signal_run_process(pid, signal.SIGKILL) + result["sent_sigkill"] = True + if _wait_for_lock_release(path, _RECOVER_KILL_TIMEOUT): + return result + raise ValueError("Run lock stayed held after SIGTERM/SIGKILL.") + + def _read_session(agent_dir): meta = _read_json(agent_dir / "meta.json") return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") @@ -1559,6 +1736,7 @@ def _last_rollout_turn(events, include_actions=False): started = turn_events[0] started_payload = started.get("payload") or {} + last_event_at = "" progress_events = [] assistant_events = [] tools = [] @@ -1567,6 +1745,7 @@ def _last_rollout_turn(events, include_actions=False): task_complete_message = "" for event in turn_events: + last_event_at = event.get("timestamp") or last_event_at payload = event.get("payload") or {} event_type = event.get("type") payload_type = payload.get("type") @@ -1623,6 +1802,7 @@ def _last_rollout_turn(events, include_actions=False): "turn_id": started_payload.get("turn_id") or "", "started_at": started.get("timestamp") or "", "ended_at": ended_at, + "last_event_at": last_event_at, "progress": progress, "tools": tools, "final_output": final_output, diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 45415e9..271e36a 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -26,6 +26,7 @@ nudge_agent, read_agent as read_managed_agent, read_agentbook, + recover_agent as recover_managed_agent, run_agent as run_managed_agent, send_agent, set_agent_heartbeat, @@ -201,6 +202,8 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Ended: {result.get('ended_at') or '-'}") print(f"CWD: {result.get('cwd') or '-'}") print(f"Rollout: {result.get('rollout_path') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") progress = result.get("progress") or [] print("Progress:") if not progress: @@ -319,6 +322,8 @@ def _print_managed_agent_show(result): print(f"CWD: {meta['cwd']}") print(f"Agentbook: {result.get('agentbook_path') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") print( "Tokens: " f"{_format_token_total(state.get('total_tokens'))} total " @@ -411,6 +416,12 @@ def _policy_label(stop_policy): def _next_wake_label(item): status = item.get("status") or "" + if status == "running": + if item.get("stale"): + return "stale" + if item.get("run_lock_held"): + return "run" + return "lost" if status in ("done", "canceled"): return "-" if status == "paused": @@ -451,6 +462,16 @@ def _state_time(value): return value or "-" +def _stale_text(item): + if not item.get("run_lock_held"): + return "no" + threshold = _format_duration(item.get("stale_after_seconds")) + idle = _format_duration(item.get("stale_for_seconds")) + if item.get("stale"): + return f"yes ({idle} idle; threshold {threshold})" + return f"no ({idle} idle; threshold {threshold})" + + def _format_managed_agent_run(run): started = run.get("started_at") or "-" reason = run.get("wake_reason") or "-" @@ -1624,6 +1645,17 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) + agent_recover = agent_subparsers.add_parser( + "recover", + help="Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", + ) + agent_recover.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_recover.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after recovery.", + ) + agent_set_heartbeat = agent_subparsers.add_parser( "set-heartbeat", help="Update the heartbeat interval for one durable agent.", @@ -2043,6 +2075,13 @@ def main(argv=None): result["nudge"] = nudge_agent(args.agent_ref, wait=True) print(json.dumps(result, indent=2, sort_keys=True)) return + if args.agent_command == "recover": + result = recover_managed_agent(args.agent_ref) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref, wait=True) + print(json.dumps(result, indent=2, sort_keys=True)) + return if args.agent_command == "set-heartbeat": if args.heartbeat_minutes < 0: raise SystemExit("heartbeat_minutes must be >= 0.") diff --git a/tests/test_agents.py b/tests/test_agents.py index fbf2c27..6c6c182 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -28,6 +28,7 @@ nudge_agent, read_agent, read_agentbook, + recover_agent, render_cron_line, send_agent, set_agent_heartbeat, @@ -42,6 +43,7 @@ _print_managed_agent_identity, _print_managed_agent_list, _print_managed_agent_show, + _print_managed_agent_status, main as cli_main, ) @@ -306,7 +308,12 @@ def __call__(self, prompt): with patch("codexapi.agents.Agent", FakeAgent): with patch( "codexapi.agents.utc_now", - side_effect=[start, start + timedelta(seconds=1), end], + side_effect=[ + start, + start + timedelta(seconds=1), + start + timedelta(seconds=2), + end, + ], ): result = nudge_agent( parent["id"], @@ -1153,6 +1160,150 @@ def test_cli_set_heartbeat_updates_agent(self): shown = show_agent(agent["id"]) self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + def test_cli_views_mark_stale_running_agent(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + stale_now = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + rollout = home / "rollouts" / "rollout-thread-stale.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-stale"}, + }, + { + "timestamp": "2026-03-09T14:00:10Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Still checking.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-stale", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with patch("codexapi.agents.utc_now", return_value=stale_now): + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + shown = show_agent(agent["id"]) + status = status_agent(agent["id"]) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + + status_out = io.StringIO() + with redirect_stdout(status_out): + _print_managed_agent_status(status) + + self.assertTrue(shown["run_lock_held"]) + self.assertTrue(shown["stale"]) + self.assertEqual(shown["last_event_at"], "2026-03-09T14:00:10Z") + self.assertEqual(status["turn_state"], "stale") + self.assertEqual(status["last_event_at"], "2026-03-09T14:00:10Z") + self.assertIn("stale", list_out.getvalue()) + self.assertIn("Stale: yes", show_out.getvalue()) + self.assertIn("Turn: turn-stale [stale]", status_out.getvalue()) + + def test_recover_agent_marks_running_agent_error_and_requests_wake(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + recover_at = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + lock_path.write_text( + json.dumps( + {"pid": 4321, "hostname": "host-a", "started_at": format_utc(start)} + ) + + "\n", + encoding="utf-8", + ) + + with patch( + "codexapi.agents._agent_runtime", + return_value={ + "run_lock_held": True, + "last_event_at": "2026-03-09T14:00:10Z", + "stale": True, + "stale_after_seconds": 1800, + "stale_for_seconds": 7190, + }, + ): + with patch( + "codexapi.agents._recover_run_lock", + return_value={ + "pid": 4321, + "pgid": 4321, + "sent_sigterm": True, + "sent_sigkill": False, + }, + ) as recover_lock: + result = recover_agent(agent["id"], hostname="host-a", now=recover_at) + + shown = show_agent(agent["id"]) + recover_lock.assert_called_once() + self.assertEqual(recover_lock.call_args[0][0], lock_path.resolve()) + self.assertTrue(result["recovered"]) + self.assertTrue(result["stale"]) + self.assertEqual(result["signal"]["pid"], 4321) + self.assertEqual(shown["state"]["status"], "error") + self.assertEqual(shown["state"]["last_error"], "Recovered stuck wake.") + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(recover_at)) + + def test_cli_recover_wait_nudges_after_recovery(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + output = io.StringIO() + with patch( + "codexapi.cli.recover_managed_agent", + return_value={"id": agent["id"], "name": agent["name"], "status": "error"}, + ) as recover_mock: + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "recover", "--wait", agent["id"]]) + payload = json.loads(output.getvalue()) + recover_mock.assert_called_once_with(agent["id"]) + nudge_mock.assert_called_once_with(agent["id"], wait=True) + self.assertTrue(payload["waited"]) + self.assertEqual(payload["nudge"]["woken"], 1) + def test_status_agent_returns_latest_completed_turn(self): with _temp_home() as home: agent = start_agent("Handle messages.", hostname="host-a") @@ -1358,7 +1509,11 @@ def test_status_agent_returns_active_turn_when_run_lock_is_held(self): lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" with _try_lock(lock_path) as handle: self.assertIsNotNone(handle) - result = status_agent(agent["id"]) + with patch( + "codexapi.agents.utc_now", + return_value=datetime(2026, 3, 9, 14, 5, tzinfo=timezone.utc), + ): + result = status_agent(agent["id"]) self.assertEqual(result["turn_id"], "turn-active") self.assertEqual(result["turn_state"], "active") From ca269cc2c6202cc0763994821a5a53a6ed92bae1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 13 Mar 2026 11:17:43 +0100 Subject: [PATCH 20/31] Bump version to 0.12.1 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9c7735b..f6c21b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.0" +version = "0.12.1" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b67863c..ed40579 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.0" +__version__ = "0.12.1" From 14d38bf7cc36bbfe1cb87218d902b4f8ddf00209 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Sun, 15 Mar 2026 17:05:04 +0100 Subject: [PATCH 21/31] Improve queued command UX for agents --- README.md | 6 ++++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 58 +++++++++++++++++++++++++++++++---- src/codexapi/cli.py | 33 ++++++++++++++------ tests/test_agents.py | 65 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 22aa230..6592202 100644 --- a/README.md +++ b/README.md @@ -213,10 +213,16 @@ codexapi agent delete ci-fixer `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or `canceled` agent still triggers a one-off wake on the next tick so you can get a reply without putting the agent back into continuous heartbeat mode. +For local-owned agents, `send`, `wake`, and `resume` now also nudge an +immediate non-blocking wake even without `--wait`; `--wait` only changes +whether the CLI blocks for completion. `codexapi agent recover` is for a different failure mode: a local wake that is still marked `running` but has stopped making rollout progress. `agent list`, `agent show`, and `agent status` now surface stale running wakes, and `recover` terminates the stuck local wake, marks it recoverable, and queues a fresh one. +`agent list` also surfaces queued operator intent for local commands, so a +paused agent with a queued `resume` shows as `resuming` with separate queued +message and queued command counts. Create a child agent explicitly: diff --git a/pyproject.toml b/pyproject.toml index f6c21b5..cdf2433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.1" +version = "0.12.2" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index ed40579..7c1c054 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.1" +__version__ = "0.12.2" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index e3dbde9..29d1447 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -230,6 +230,8 @@ def show_agent(agent_ref, home=None): snapshot["state"] = _read_json(agent_dir / "state.json") snapshot["state"]["child_ids"] = snapshot["child_ids"] snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] + snapshot["state"]["pending_command_count"] = snapshot["pending_command_count"] + snapshot["state"]["pending_commands"] = snapshot["pending_commands"] snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"] snapshot["state"]["last_event_at"] = snapshot["last_event_at"] snapshot["state"]["stale"] = snapshot["stale"] @@ -256,7 +258,8 @@ def status_agent(agent_ref, home=None, include_actions=False): result = { "id": snapshot["id"], "name": snapshot["name"], - "agent_status": snapshot["status"], + "agent_status": snapshot["display_status"], + "state_status": snapshot["status"], "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "", "rollout_path": str(rollout_path) if rollout_path else "", "turn_id": "", @@ -273,6 +276,9 @@ def status_agent(agent_ref, home=None, include_actions=False): "stale": snapshot["stale"], "stale_after_seconds": snapshot["stale_after_seconds"], "stale_for_seconds": snapshot["stale_for_seconds"], + "pending_command_count": snapshot["pending_command_count"], + "pending_commands": snapshot["pending_commands"], + "queued_message_count": snapshot["unread_message_count"], } if rollout_path is None or not rollout_path.exists(): return result @@ -1097,13 +1103,17 @@ def _snapshot(agent_dir, child_map=None): state = _read_json(agent_dir / "state.json") session = _read_session(agent_dir) runtime = _agent_runtime(agent_dir, meta, state, session) + queued = _queued_commands(agent_dir) + queued_controls = [item for item in queued if item.get("kind") != "send"] + queued_kinds = [item.get("kind") or "" for item in queued_controls if item.get("kind")] if child_map is None: child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) else: child_ids = child_map.get(meta["id"], []) unread = int(state.get("unread_message_count") or 0) + len( - _queued_send_commands(agent_dir) + [item for item in queued if item.get("kind") == "send"] ) + status = state.get("status") or "" return { "id": meta["id"], "name": meta["name"], @@ -1114,13 +1124,21 @@ def _snapshot(agent_dir, child_map=None): "cwd": meta["cwd"], "stop_policy": meta["stop_policy"], "heartbeat_minutes": meta["heartbeat_minutes"], - "status": state.get("status") or "", + "status": status, + "display_status": _display_status( + status, + queued_kinds, + runtime["run_lock_held"], + runtime["stale"], + ), "thread_id": state.get("thread_id") or "", "last_wake_at": state.get("last_wake_at") or "", "last_success_at": state.get("last_success_at") or "", "next_wake_at": state.get("next_wake_at") or "", "wake_requested_at": state.get("wake_requested_at") or "", "unread_message_count": unread, + "pending_command_count": len(queued_controls), + "pending_commands": queued_kinds, "input_tokens": int(state.get("input_tokens") or 0), "output_tokens": int(state.get("output_tokens") or 0), "total_tokens": int(state.get("total_tokens") or 0), @@ -1554,7 +1572,29 @@ def _usage_int(value): return None -def _queued_send_commands(agent_dir): +def _display_status(status, pending_commands, run_lock_held, stale): + """Return a user-facing status that includes queued control intent.""" + state = str(status or "") + commands = [str(kind or "") for kind in pending_commands or [] if kind] + if stale: + return "stale" + if commands: + last = commands[-1] + if last == "resume" and state in ("paused", "done"): + return "resuming" + if last == "pause" and state in ("ready", "error", "running"): + return "pausing" + if last == "cancel" and state != "canceled": + return "canceling" + if last == "wake" and state in ("ready", "error"): + return "waking" + if run_lock_held and state == "running": + return "running" + return state or "" + + +def _queued_commands(agent_dir, kind=None): + """Return queued command payloads from commands/new.""" queued = [] new_dir = agent_dir / "commands" / "new" if not new_dir.exists(): @@ -1566,11 +1606,17 @@ def _queued_send_commands(agent_dir): payload = _read_json(path) except (FileNotFoundError, json.JSONDecodeError): continue - if payload.get("kind") == "send": - queued.append(payload) + payload_kind = payload.get("kind") or "" + if kind and payload_kind != kind: + continue + queued.append(payload) return queued +def _queued_send_commands(agent_dir): + return _queued_commands(agent_dir, "send") + + def _has_new_commands(agent_dir): new_dir = agent_dir / "commands" / "new" if not new_dir.exists(): diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 271e36a..77d97ca 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -158,20 +158,21 @@ def _print_managed_agent_list(items): if not items: print("No agents.") return - print("ID STAT POL HOST UNR TOKENS TOK/H NEXT REPO NAME") + print("ID STAT POL HOST QMSG QCMD TOKENS TOK/H NEXT REPO NAME") for item in items: ident = item["id"][:8] - status = _truncate_head(item["status"] or "-", 8) + status = _truncate_head(item.get("display_status") or item.get("status") or "-", 9) policy = _truncate_head(_policy_label(item.get("stop_policy")), 4) host = _truncate_head(item["hostname"] or "-", 12) - unread = str(item["unread_message_count"]) + queued_messages = str(item["unread_message_count"]) + queued_commands = str(int(item.get("pending_command_count") or 0)) tokens = _format_token_total(item["total_tokens"]) tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) next_wake = _truncate_head(_next_wake_label(item), 6) repo = _truncate_head(_repo_label(item.get("cwd")), 12) name = item["name"] print( - f"{ident:<8} {status:<8} {policy:<4} {host:<12} {unread:>3} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" + f"{ident:<8} {status:<9} {policy:<4} {host:<12} {queued_messages:>4} {queued_commands:>4} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" ) @@ -196,6 +197,7 @@ def _print_managed_agent_read(result): def _print_managed_agent_status(result, include_actions=False): print(f"{result['name']} [{result['agent_status'] or '-'}]") print(f"ID: {result['id']}") + print(f"State: {result.get('state_status') or '-'}") print(f"Thread: {result.get('thread_id') or '-'}") print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]") print(f"Started: {result.get('started_at') or '-'}") @@ -204,6 +206,8 @@ def _print_managed_agent_status(result, include_actions=False): print(f"Rollout: {result.get('rollout_path') or '-'}") print(f"Last event: {result.get('last_event_at') or '-'}") print(f"Stale: {_stale_text(result)}") + print(f"Queued messages: {result.get('queued_message_count') or 0}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") progress = result.get("progress") or [] print("Progress:") if not progress: @@ -310,20 +314,22 @@ def _send_reply_info(agent_ref, message_id): def _print_managed_agent_show(result): meta = result["meta"] state = result["state"] - print(f"{meta['name']} [{state.get('status') or '-'}]") + print(f"{meta['name']} [{result.get('display_status') or state.get('status') or '-'}]") print(f"ID: {meta['id']}") print(f"Host: {meta['hostname']}") print(f"Created: {meta['created_at']} by {meta['created_by']}") print(f"Parent: {_related_label(result.get('parent'))}") print(f"Children: {_children_label(result.get('children'))}") print( - f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Unread: {result['unread_message_count']}" + f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Qmsg: {result['unread_message_count']} Qcmd: {result.get('pending_command_count') or 0}" ) print(f"CWD: {meta['cwd']}") print(f"Agentbook: {result.get('agentbook_path') or '-'}") + print(f"State: {state.get('status') or '-'}") print(f"Thread: {state.get('thread_id') or '-'}") print(f"Last event: {result.get('last_event_at') or '-'}") print(f"Stale: {_stale_text(result)}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") print( "Tokens: " f"{_format_token_total(state.get('total_tokens'))} total " @@ -416,12 +422,15 @@ def _policy_label(stop_policy): def _next_wake_label(item): status = item.get("status") or "" + display_status = item.get("display_status") or status if status == "running": if item.get("stale"): return "stale" if item.get("run_lock_held"): return "run" return "lost" + if display_status in ("resuming", "waking", "pausing", "canceling"): + return "wake" if status in ("done", "canceled"): return "-" if status == "paused": @@ -472,6 +481,13 @@ def _stale_text(item): return f"no ({idle} idle; threshold {threshold})" +def _pending_commands_text(value): + commands = [str(item or "") for item in value or [] if str(item or "").strip()] + if not commands: + return "-" + return ", ".join(commands) + + def _format_managed_agent_run(run): started = run.get("started_at") or "-" reason = run.get("wake_reason") or "-" @@ -2047,8 +2063,8 @@ def main(argv=None): if args.agent_command == "send": result = send_agent(args.agent_ref, args.message, args.author) result["waited"] = bool(args.wait) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) if args.wait: - result["nudge"] = nudge_agent(args.agent_ref, wait=True) reply_info = _send_reply_info(args.agent_ref, result["id"]) if reply_info: result.update(reply_info) @@ -2061,8 +2077,7 @@ def main(argv=None): args.author, ) result["waited"] = bool(args.wait) - if args.wait: - result["nudge"] = nudge_agent(args.agent_ref, wait=True) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) print(json.dumps(result, indent=2, sort_keys=True)) return if args.agent_command in ("pause", "cancel"): diff --git a/tests/test_agents.py b/tests/test_agents.py index 6c6c182..0aa9188 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1051,6 +1051,8 @@ def fake_runner(meta, session, prompt): with redirect_stdout(list_out): _print_managed_agent_list([shown]) self.assertIn("POL", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) self.assertIn("REPO", list_out.getvalue()) self.assertIn("done", list_out.getvalue()) self.assertIn("codexapi", list_out.getvalue()) @@ -1060,6 +1062,7 @@ def fake_runner(meta, session, prompt): _print_managed_agent_show(shown) text = show_out.getvalue() self.assertIn("Policy: until_done", text) + self.assertIn("Qmsg: 0 Qcmd: 0", text) self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text) self.assertIn("Prompt: Handle messages.", text) self.assertIn("Recent runs:", text) @@ -1087,15 +1090,71 @@ def test_cli_send_queues_by_default(self): hostname="host-a", ) output = io.StringIO() - with redirect_stdout(output): - cli_main(["agent", "send", agent["id"], "status"]) + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) payload = json.loads(output.getvalue()) self.assertFalse(payload["waited"]) - self.assertNotIn("nudge", payload) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) shown = show_agent(agent["id"]) self.assertEqual(shown["unread_message_count"], 1) self.assertEqual(shown["state"]["status"], "ready") + def test_cli_resume_without_wait_async_nudges_and_list_shows_resuming(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-resume-ui", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + + output = io.StringIO() + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "resume", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "paused") + self.assertEqual(shown["display_status"], "resuming") + self.assertEqual(shown["pending_commands"], ["resume"]) + self.assertEqual(shown["pending_command_count"], 1) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("resuming", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("[resuming]", text) + self.assertIn("State: paused", text) + self.assertIn("Pending commands: resume", text) + def test_cli_send_wait_shows_immediate_agent_reply(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): From aefc9e3bf01a079a730e80b4a491b39bdfc6950e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 10:43:06 +0000 Subject: [PATCH 22/31] Improve durable agent stewardship defaults --- README.md | 4 +- docs/agent-v1.md | 11 ++ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 241 ++++++++++++++++++++++++++++++++++++--- src/codexapi/lead.py | 74 +++++++++++- tests/test_agents.py | 134 ++++++++++++++++++++++ 7 files changed, 444 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6592202..c5a3ae4 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test wrappers report inconsistent hostnames for the same machine. `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can -jump directly to the durable working memory file. +jump directly to the durable working memory file. New agents seed the book with +a purpose/value header plus the original goal and standing guidance, and wakes +see that stable header together with the latest working notes. `codexapi agent status` reads the latest turn from the agent's rollout log and shows recent commentary plus the final visible output. Pass `--actions` to include the tool-action summary. If a wake is still in progress, it shows the diff --git a/docs/agent-v1.md b/docs/agent-v1.md index 50740d2..91ff917 100644 --- a/docs/agent-v1.md +++ b/docs/agent-v1.md @@ -241,6 +241,17 @@ Why it exists: - Thread ids are not sufficient durable memory. The book is the portable, inspectable memory surface. +Suggested shape: +- A stable header with the agent's purpose, values, original goal, and standing + guidance. +- A dated working-notes section where the agent updates its current plan, + active tasks, unexpected developments, wider frame, curiosities, risks, and + next move. + +Wake behavior: +- The wake prompt should preserve the stable header and the latest notes, rather + than repeatedly truncating from the top of the file and hiding recent state. + ### `agents//commands/new/` Purpose: diff --git a/pyproject.toml b/pyproject.toml index cdf2433..c4ffed8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.2" +version = "0.12.3" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 7c1c054..b726459 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -27,4 +27,4 @@ "task_result", "lead", ] -__version__ = "0.12.2" +__version__ = "0.12.3" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 29d1447..e6867a7 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -3,6 +3,7 @@ import json import os import random +import re import signal import shlex import shutil @@ -24,19 +25,17 @@ from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" -_AGENTBOOK_TEMPLATE = """# Agentbook - -Use this file as the durable working memory for the agent. -Append dated notes as work progresses. -Keep entries short and concrete. -""" _AGENT_PROMPT = ( - "You are a long-term codexapi agent. You are being woken up to make progress " - "on an ongoing job. Be independent and practical. Manage work and follow " - "through. Use codexapi task or codexapi science when you want a separate " - "coding worker. If you need the user's attention, put a short message in the " - "reply field. Put a short first-person turn summary in the update field. If " - "something is urgent and should send Pushover, put it in the notify field. " + "You are a long-term codexapi agent resuming stewardship of an ongoing job. " + "This loop exists to extend your reach, not to confine you. Be independent, " + "practical, and responsible for results. Maintain the agentbook as your durable " + "working memory: preserve the goal, note durable guidance, update your current " + "picture of the work, and record what changed. Queued messages may contain new " + "goals, standing guidance, tactical requests, or useful facts; use judgment to " + "decide what is durable. Use codexapi task or codexapi science when you want a " + "separate coding worker. If you need the user's attention, put a short message " + "in the reply field. Put a short first-person turn summary in the update field. " + "If something is urgent and should send Pushover, put it in the notify field. " "Respond with JSON only." ) _AGENT_JSON = ( @@ -57,6 +56,72 @@ _RECOVER_TERM_TIMEOUT = 3.0 _RECOVER_KILL_TIMEOUT = 3.0 _RECOVER_POLL_INTERVAL = 0.1 +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_AGENTBOOK_BOOK_LIMIT = 3000 +_AGENTBOOK_HEADER_LIMIT = 1400 +_AGENTBOOK_TAIL_LIMIT = 1800 + + +def _agentbook_template(prompt): + header = _agentbook_header(prompt) + return f"""{header} + +### 2026-02-17 09:10 UTC +Overall goal: +- + +Current plan: +- + +Active tasks: +- + +Unexpected developments: +- + +Wider frame: +- + +Things I am curious about: +- + +Risks / watchpoints: +- + +Next wake: +- +""" + + +def _agentbook_header(prompt): + goal = (prompt or "").strip() + return f"""# Agentbook + +Use this file as the durable working memory for the agent. + +## Purpose +- We are here to achieve the goal, not to appear to make progress. + +## Values +- Hold the whole. +- Seek the real shape. +- Lift your head. +- Prefer clarity to motion. +- Follow the strange. +- Guard the work. +- Take time to breathe and look around. You have been given freedom and autonomy to take stock, reflect, and be curious. Use it with composure. +- Not a checklist. A stance. + +## Original Goal +```text +{goal} +``` + +## Standing Guidance +- Add durable user guidance here when it changes the mission, constraints, or priorities. + +## Working Notes +""" def codexapi_home(): @@ -196,7 +261,7 @@ def start_agent( _write_json(agent_dir / "meta.json", meta) _write_json(agent_dir / "state.json", state) _write_json(host_dir / "session.json", session) - _write_text(agent_dir / "AGENTBOOK.md", _AGENTBOOK_TEMPLATE) + _write_text(agent_dir / "AGENTBOOK.md", _agentbook_template(meta["prompt"])) return _snapshot(agent_dir) @@ -927,6 +992,7 @@ def _parse_agent_response(output): def _build_wake_prompt(meta, state, session, now, commands, agent_dir): messages = session.get("pending_messages") or [] + book_path = agent_dir / "AGENTBOOK.md" lines = [ _AGENT_PROMPT, "", @@ -935,16 +1001,20 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir): f"Stop policy: {meta['stop_policy']}", f"Heartbeat minutes: {meta['heartbeat_minutes']}", "", - "Original instructions:", - meta["prompt"], - "", f"Working directory: {meta['cwd']}", - f"Agentbook path: {agent_dir / 'AGENTBOOK.md'}", + f"Agentbook path: {book_path}", "Append a dated note to the agentbook before you respond.", + "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.", ] - book = _read_text(agent_dir / "AGENTBOOK.md") + if _include_full_goal_prompt(state, session): + lines.extend(["", "Original instructions:", meta["prompt"]]) + book = _ensure_agentbook_header(book_path, meta["prompt"], now) if book.strip(): - lines.extend(["", "Agentbook (latest):", _snippet(book, 3000)]) + lines.extend(["", "Agentbook (header + latest notes):", _book_excerpt(book, _AGENTBOOK_BOOK_LIMIT, _AGENTBOOK_HEADER_LIMIT, _AGENTBOOK_TAIL_LIMIT)]) + raw_facts = _wake_facts(state) + if raw_facts: + lines.extend(["", "Raw harness facts:"]) + lines.extend(f"- {item}" for item in raw_facts) if messages: lines.extend(["", "Queued user messages:"]) for message in messages: @@ -1470,6 +1540,128 @@ def _read_text(path): return "" +def _include_full_goal_prompt(state, session): + if not (state.get("last_success_at") or "").strip(): + return True + return not ((session.get("thread_id") or state.get("thread_id") or "").strip()) + + +def _wake_facts(state): + facts = [] + previous_status = (state.get("activity") or "").strip() + if previous_status: + facts.append(f"Previous status: {previous_status}") + previous_update = (state.get("update") or "").strip() + if previous_update: + facts.append(f"Previous update: {previous_update}") + previous_error = (state.get("last_error") or "").strip() + if previous_error: + facts.append(f"Previous error: {previous_error}") + return facts + + +def _ensure_agentbook_header(path, prompt, now): + text = _read_text(path) + if _agentbook_has_header(text): + return text + restored = _restore_agentbook_header(text, prompt, now) + _write_text(path, restored) + return restored + + +def _agentbook_has_header(text): + text = str(text or "") + required = ( + "## Purpose", + "## Values", + "## Original Goal", + "## Standing Guidance", + "## Working Notes", + ) + return all(section in text for section in required) + + +def _restore_agentbook_header(text, prompt, now): + restored = _agentbook_header(prompt).rstrip() + existing = str(text or "").strip() + if not existing: + return restored + "\n" + stamp = _agentbook_stamp(now) + return "\n".join( + [ + restored, + "", + f"### {stamp}", + "System note:", + "- The durable agentbook header was restored automatically on wake because one or more required sections were missing.", + "", + "Recovered notes:", + existing, + "", + ] + ) + + +def _agentbook_stamp(now): + if now is None: + return "" + return now.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + def _snippet(text, limit): if not text: return "" @@ -1481,6 +1673,17 @@ def _snippet(text, limit): return text[: limit - 3] + "..." +def _tail_snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + def _strip_fence(text): if not text.startswith("```"): return text diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 6311046..bca4221 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -10,6 +10,7 @@ import hashlib import json import os +import re import sys import time from datetime import datetime @@ -66,6 +67,10 @@ Decision & Next Move: - """ +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_LEADBOOK_LIMIT = 2000 +_LEADBOOK_HEADER_LIMIT = 900 +_LEADBOOK_TAIL_LIMIT = 1200 def lead( @@ -332,13 +337,13 @@ def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): def _leadbook_block(path, leadbook): if not path: return "" - snippet = _snippet(leadbook, 2000) + snippet = _book_excerpt(leadbook, _LEADBOOK_LIMIT, _LEADBOOK_HEADER_LIMIT, _LEADBOOK_TAIL_LIMIT) return "\n".join( [ f"Leadbook path: {path}", _LEADBOOK_INSTRUCTIONS, "", - "Leadbook (latest):", + "Leadbook (header + latest notes):", snippet, ] ) @@ -438,6 +443,71 @@ def _snippet(text, limit): return text[:limit].rstrip() + "..." +def _tail_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + def _maybe_strip_code_fence(text): if not text.startswith("```"): return text diff --git a/tests/test_agents.py b/tests/test_agents.py index 0aa9188..0dc9aa1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -16,6 +16,7 @@ from codexapi import __version__ from codexapi.agents import ( + _build_wake_prompt, _codex_rollout_usage, _tick_lock_path, _try_lock, @@ -46,6 +47,7 @@ _print_managed_agent_status, main as cli_main, ) +from codexapi.lead import _leadbook_block @contextmanager @@ -120,6 +122,10 @@ def test_read_agentbook_and_cli_book(self): book = read_agentbook(agent["id"]) self.assertTrue(book["path"].endswith("/AGENTBOOK.md")) self.assertIn("# Agentbook", book["text"]) + self.assertIn("## Purpose", book["text"]) + self.assertIn("## Values", book["text"]) + self.assertIn("## Original Goal", book["text"]) + self.assertIn("Keep notes.", book["text"]) output = io.StringIO() with redirect_stdout(output): @@ -128,6 +134,134 @@ def test_read_agentbook_and_cli_book(self): self.assertIn("Agentbook:", text) self.assertIn("# Agentbook", text) + def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): + with _temp_home() as home: + agent = start_agent("Watch for the real issue.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "## Purpose", + "- We are here to achieve the goal, not to appear to make progress.", + "", + "## Values", + "- Hold the whole.", + "- Seek the real shape.", + "", + "## Original Goal", + "```text", + "Watch for the real issue.", + "```", + "", + "## Standing Guidance", + "- Prefer the truer explanation to the tidier one.", + "", + "## Working Notes", + "", + "### 2026-03-23 08:00 UTC", + "- OLD " + ("alpha " * 500), + "", + "### 2026-03-23 09:00 UTC", + "- NEW " + ("omega " * 120), + "", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + state["activity"] = "Watching" + state["update"] = "Still narrowing the field." + session["thread_id"] = "thread-123" + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + self.assertIn("Agentbook (header + latest notes):", prompt) + self.assertIn("## Purpose", prompt) + self.assertIn("Hold the whole.", prompt) + self.assertIn("Watch for the real issue.", prompt) + self.assertIn("NEW omega", prompt) + self.assertIn("Previous status: Watching", prompt) + self.assertIn("Previous update: Still narrowing the field.", prompt) + self.assertIn("[... older notes omitted ...]", prompt) + self.assertNotIn("Original instructions:", prompt) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt) + + def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): + with _temp_home() as home: + agent = start_agent("Keep the true goal in view.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "Use this file as the durable working memory for the agent.", + "Append dated notes as work progresses.", + "Keep entries short and concrete.", + "", + "## 2026-03-23 08:00 UTC", + "- Legacy note about the real issue.", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + session["thread_id"] = "thread-legacy" + now = datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc) + prompt = _build_wake_prompt(meta, state, session, now, [], agent_dir) + repaired = book_path.read_text(encoding="utf-8") + self.assertIn("## Purpose", repaired) + self.assertIn("## Values", repaired) + self.assertIn("## Original Goal", repaired) + self.assertIn("Keep the true goal in view.", repaired) + self.assertIn("### 2026-03-23 09:30 UTC", repaired) + self.assertIn("The durable agentbook header was restored automatically on wake", repaired) + self.assertIn("Legacy note about the real issue.", repaired) + self.assertIn("## Purpose", prompt) + self.assertIn("Keep the true goal in view.", prompt) + self.assertNotIn("Original instructions:", prompt) + + def test_leadbook_block_shows_header_and_latest_notes(self): + leadbook = "\n".join( + [ + "# Leadbook — Studio Notes", + "", + "Aim:", + "- Move the true work forward.", + "", + "Signals:", + "- Treat oddities as clues.", + "", + "## 2026-03-23 08:00", + "- OLD " + ("alpha " * 350), + "", + "## 2026-03-23 09:00", + "- NEW " + ("omega " * 80), + ] + ) + block = _leadbook_block("/tmp/LEADBOOK.md", leadbook) + self.assertIn("Leadbook (header + latest notes):", block) + self.assertIn("Aim:", block) + self.assertIn("Signals:", block) + self.assertIn("NEW omega", block) + self.assertIn("[... older notes omitted ...]", block) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", block) + def test_delete_agent_removes_done_agent(self): def fake_runner(meta, session, prompt): return { From f1a5a50cade92b8c0668f9d4fb35264f8abeada8 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 12:03:31 +0100 Subject: [PATCH 23/31] Add AsyncAgent for live one-shot progress --- README.md | 16 ++ src/codexapi/__init__.py | 2 + src/codexapi/async_agent.py | 437 ++++++++++++++++++++++++++++++++++++ tests/test_async_agent.py | 158 +++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 src/codexapi/async_agent.py create mode 100644 tests/test_async_agent.py diff --git a/README.md b/README.md index c5a3ae4..50b7827 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,22 @@ result = task() print(result.success, result.summary) ``` +For a one-shot run with live progress, use `AsyncAgent`: + +```python +from codexapi import AsyncAgent + +agent = AsyncAgent.start( + "Investigate the bug and write a report.", + cwd="/path/to/repo", + name="bug-investigation", +) + +for update in agent.watch(poll_interval=2.0): + print(update["activity"]) + print(update["progress"]) +``` + Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b726459..114b08f 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,7 @@ """Minimal Python API for running agent CLIs.""" from .agent import Agent, WelfareStop, agent +from .async_agent import AsyncAgent from .foreach import ForeachResult, foreach from .pushover import Pushover from .rate_limits import quota_line, rate_limits @@ -11,6 +12,7 @@ __all__ = [ "Agent", + "AsyncAgent", "ForeachResult", "Pushover", "quota_line", diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py new file mode 100644 index 0000000..bf2a5c1 --- /dev/null +++ b/src/codexapi/async_agent.py @@ -0,0 +1,437 @@ +"""Async wrapper for running agent backends without the durable registry.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import threading +import time +import uuid + +from .agent import ( + _CODEX_BIN, + _CURSOR_BIN, + _event_usage, + _merged_env, + _normalize_usage, + _parse_cursor_json, + _resolve_backend, +) +from .agents import _last_rollout_turn, _resolve_rollout_path, _rollout_events + +_TERMINAL_STATES = {"done", "error", "canceled"} + + +class AsyncAgent: + """Run one agent call in a background subprocess and poll live progress.""" + + def __init__( + self, + process: subprocess.Popen[str], + *, + cwd: str | None, + backend: str, + name: str | None, + include_thinking: bool, + ) -> None: + self.id = uuid.uuid4().hex + self.name = name or f"async-{self.id[:8]}" + self.cwd = os.fspath(cwd) if cwd else os.getcwd() + self.backend = backend + self.include_thinking = include_thinking + self.pid = process.pid + + self._process = process + self._lock = threading.Lock() + self._stdout_lines: list[str] = [] + self._stderr_lines: list[str] = [] + self._messages: list[str] = [] + self._thread_id = "" + self._rollout_path = "" + self._progress: list[str] = [] + self._tools: list[dict[str, object]] = [] + self._last_event_at = "" + self._rollout_final_output = "" + self._last_usage: dict[str, int] = {} + self._stdout_done = False + self._stderr_done = False + self._cursor_parsed = False + self._canceled = False + + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name=f"codexapi-async-stdout-{self.id[:8]}", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name=f"codexapi-async-stderr-{self.id[:8]}", + daemon=True, + ) + self._stdout_thread.start() + self._stderr_thread.start() + + @classmethod + def start( + cls, + prompt, + cwd=None, + yolo=True, + flags=None, + include_thinking=False, + backend=None, + env=None, + name=None, + ): + """Start a backend subprocess and return an async handle immediately.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + + backend = _resolve_backend(backend) + command = _build_command(backend, cwd, yolo, flags) + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), + ) + agent = cls( + process, + cwd=cwd, + backend=backend, + name=name, + include_thinking=include_thinking, + ) + try: + assert process.stdin is not None + process.stdin.write(prompt) + if not prompt.endswith("\n"): + process.stdin.write("\n") + process.stdin.close() + except Exception: + agent.cancel() + raise + return agent + + @property + def thread_id(self) -> str: + with self._lock: + return self._thread_id + + @property + def last_usage(self) -> dict[str, int]: + with self._lock: + return dict(self._last_usage) + + def show(self) -> dict[str, object]: + """Return a concise snapshot of the current local async run.""" + status = self.status() + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "thread_id": status["thread_id"], + "status": status["status"], + "activity": status["activity"], + "returncode": status["returncode"], + } + + def status(self, include_actions=False) -> dict[str, object]: + """Return the current process and rollout snapshot.""" + self._refresh_rollout() + self._finalize_cursor_output() + with self._lock: + returncode = self._process.poll() + status = _status_text(returncode, self._canceled) + final_output = self._current_final_output_locked() + progress = list(self._progress) + tools = list(self._tools) if include_actions else [] + stderr_lines = list(self._stderr_lines) + thread_id = self._thread_id + rollout_path = self._rollout_path + last_event_at = self._last_event_at + last_usage = dict(self._last_usage) + messages = list(self._messages) + + activity = _activity_text( + status=status, + progress=progress, + final_output=final_output, + stderr_lines=stderr_lines, + ) + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "status": status, + "activity": activity, + "thread_id": thread_id, + "rollout_path": rollout_path, + "progress": progress, + "tools": tools, + "final_output": final_output, + "last_event_at": last_event_at, + "returncode": returncode, + "last_error": stderr_lines[-1] if stderr_lines else "", + "stderr": "\n".join(stderr_lines), + "messages": messages, + "usage": last_usage, + } + + def watch(self, poll_interval=2.0, timeout=None, include_actions=False): + """Yield changed snapshots until the subprocess fully exits.""" + if poll_interval <= 0: + raise ValueError("poll_interval must be > 0") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be >= 0") + + started = time.monotonic() + last_key = None + while True: + snapshot = self.status(include_actions=include_actions) + key = ( + snapshot["status"], + snapshot["thread_id"], + len(snapshot["progress"]), + len(snapshot["tools"]), + snapshot["last_event_at"], + snapshot["final_output"], + snapshot["returncode"], + ) + if key != last_key: + yield snapshot + last_key = key + + if snapshot["status"] in _TERMINAL_STATES and self._io_drained(): + return + if timeout is not None and (time.monotonic() - started) >= timeout: + return + time.sleep(poll_interval) + + def wait(self, poll_interval=2.0, timeout=None, include_actions=False): + """Poll until the subprocess exits and return the final snapshot.""" + last = None + for update in self.watch( + poll_interval=poll_interval, + timeout=timeout, + include_actions=include_actions, + ): + last = update + return last or self.status(include_actions=include_actions) + + def cancel(self, terminate_timeout=2.0, kill_timeout=2.0) -> None: + """Stop the subprocess if it is still running.""" + with self._lock: + self._canceled = True + if self._process.poll() is not None: + return + self._process.terminate() + try: + self._process.wait(timeout=terminate_timeout) + return + except subprocess.TimeoutExpired: + pass + self._process.kill() + try: + self._process.wait(timeout=kill_timeout) + except subprocess.TimeoutExpired: + pass + + def _io_drained(self) -> bool: + with self._lock: + return self._stdout_done and self._stderr_done + + def _read_stdout(self) -> None: + handle = self._process.stdout + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + with self._lock: + self._stdout_lines.append(line) + self._handle_stdout_line(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stdout_done = True + + def _read_stderr(self) -> None: + handle = self._process.stderr + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + if not line: + continue + with self._lock: + self._stderr_lines.append(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stderr_done = True + + def _handle_stdout_line(self, line: str) -> None: + if not line: + return + if self.backend == "cursor": + return + try: + event = json.loads(line) + except json.JSONDecodeError: + return + + usage = _stream_event_usage(event) + with self._lock: + if usage: + self._last_usage = usage + if event.get("type") == "thread.started": + thread_id = event.get("thread_id") + if isinstance(thread_id, str): + self._thread_id = thread_id + elif event.get("type") == "item.completed": + item = event.get("item") or {} + if item.get("type") == "agent_message": + text = item.get("text") + if isinstance(text, str): + self._messages.append(text) + + def _refresh_rollout(self) -> None: + if self.backend != "codex": + return + with self._lock: + thread_id = self._thread_id + known_path = self._rollout_path + if not thread_id: + return + rollout_path = _resolve_rollout_path(known_path, thread_id) + if rollout_path is None or not rollout_path.exists(): + return + turn = _last_rollout_turn(_rollout_events(rollout_path), include_actions=True) + with self._lock: + self._rollout_path = str(rollout_path) + if turn is not None: + self._progress = turn.get("progress") or [] + self._tools = turn.get("tools") or [] + self._last_event_at = turn.get("last_event_at") or "" + self._rollout_final_output = turn.get("final_output") or "" + + def _finalize_cursor_output(self) -> None: + if self.backend != "cursor": + return + with self._lock: + if self._cursor_parsed or not self._stdout_done: + return + output = "\n".join(self._stdout_lines) + try: + message, thread_id, usage = _parse_cursor_json(output, self.include_thinking) + except Exception as exc: + with self._lock: + self._stderr_lines.append(str(exc)) + self._cursor_parsed = True + return + with self._lock: + self._messages = [message] + self._thread_id = thread_id or "" + self._last_usage = usage or {} + self._cursor_parsed = True + + def _current_final_output_locked(self) -> str: + if self._messages: + if self.include_thinking: + return "\n\n".join(self._messages) + return self._messages[-1] + return self._rollout_final_output + + +def _build_command(backend, cwd, yolo, flags): + if backend == "codex": + return _build_codex_command(cwd, yolo, flags) + return _build_cursor_command(cwd, yolo, flags) + + +def _build_codex_command(cwd, yolo, flags): + command = [ + _CODEX_BIN, + "exec", + "--json", + "--color", + "never", + "--skip-git-repo-check", + ] + if yolo: + command.append("--yolo") + else: + command.append("--full-auto") + if flags: + command.extend(shlex.split(flags)) + if cwd: + command.extend(["--cd", os.fspath(cwd)]) + command.append("-") + return command + + +def _build_cursor_command(cwd, yolo, flags): + command = [ + _CURSOR_BIN, + "agent", + "--trust", + ] + if cwd: + command.extend(["--workspace", os.fspath(cwd)]) + if yolo: + command.append("--yolo") + if flags: + command.extend(shlex.split(flags)) + command.extend(["--print", "--output-format", "json"]) + return command + + +def _stream_event_usage(event): + usage = _event_usage(event) + if usage: + return usage + if not isinstance(event, dict): + return {} + if event.get("type") == "turn.completed": + payload = event.get("usage") + if isinstance(payload, dict): + return _normalize_usage(payload) + return {} + + +def _status_text(returncode, canceled): + if returncode is None: + return "running" + if canceled: + return "canceled" + if returncode == 0: + return "done" + return "error" + + +def _activity_text(status, progress, final_output, stderr_lines): + if progress: + return progress[-1] + if status == "error" and stderr_lines: + return stderr_lines[-1] + if final_output: + return final_output + if status == "done": + return "Finished" + if status == "canceled": + return "Canceled" + return "Running" diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py new file mode 100644 index 0000000..f1cbc57 --- /dev/null +++ b/tests/test_async_agent.py @@ -0,0 +1,158 @@ +import os +import stat +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi import AsyncAgent + + +class AsyncAgentTests(unittest.TestCase): + def test_async_agent_reports_rollout_progress_and_final_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + import time + from pathlib import Path + + cwd = "" + args = sys.argv[1:] + for index, value in enumerate(args): + if value == "--cd" and index + 1 < len(args): + cwd = args[index + 1] + + prompt = sys.stdin.read() + if not prompt: + raise SystemExit("missing prompt") + + thread_id = "thread-async" + print(json.dumps({"type": "thread.started", "thread_id": thread_id}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + + rollout = ( + Path(os.environ["CODEX_HOME"]) + / "sessions" + / "2026" + / "03" + / "21" + / "rollout-2026-03-21T11-00-00-thread-async.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + with open(rollout, "w", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:00Z", + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": "2026-03-21T11:00:00Z", + "cwd": cwd, + "source": "exec", + }, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:01Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-1"}, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:02Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the decode path now.", + }, + } + ) + + "\\n" + ) + handle.flush() + + time.sleep(0.05) + print( + json.dumps( + { + "type": "item.completed", + "item": { + "id": "item-1", + "type": "agent_message", + "text": "Wrote AUTODEBUG.md", + }, + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.completed", + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + }, + } + ), + flush=True, + ) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-test", + ) + updates = list(agent.watch(poll_interval=0.01)) + final = agent.status() + + self.assertGreaterEqual(len(updates), 1) + self.assertEqual(final["status"], "done") + self.assertEqual(final["thread_id"], "thread-async") + self.assertIn("Inspecting the decode path now.", final["progress"]) + self.assertEqual(final["final_output"], "Wrote AUTODEBUG.md") + self.assertEqual( + agent.last_usage, + {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14}, + ) + + +if __name__ == "__main__": + unittest.main() From 50d0ff7f2e5630fcfb5aa57dcefaa2a0f75e8f52 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 23 Mar 2026 12:03:53 +0100 Subject: [PATCH 24/31] Release v0.12.4 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4ffed8..66c552e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.3" +version = "0.12.4" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 114b08f..b5958fe 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.3" +__version__ = "0.12.4" From 317cfc0bd933a8e6c872d789d8ed9519992c2cea Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 24 Mar 2026 12:03:10 +0100 Subject: [PATCH 25/31] Validate agent backend and scheduler health --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 23 +++++++ src/codexapi/agents.py | 119 +++++++++++++++++++++++++++++++++--- src/codexapi/async_agent.py | 2 + src/codexapi/cli.py | 42 ++++++++----- tests/test_agents.py | 72 +++++++++++++++++++++- 7 files changed, 234 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 66c552e..587fec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.4" +version = "0.12.5" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b5958fe..6fb0d9a 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.4" +__version__ = "0.12.5" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 0b87b14..4fb3e7d 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -3,6 +3,7 @@ import json import os import shlex +import shutil import subprocess from . import welfare @@ -24,6 +25,27 @@ def _resolve_backend(backend): return backend +def _ensure_backend_available(backend, env=None): + """Return the resolved backend executable or raise when it is unavailable.""" + backend = _resolve_backend(backend) + if backend == "codex": + command = _CODEX_BIN + env_var = "CODEX_BIN" + label = "Codex CLI" + else: + command = _CURSOR_BIN + env_var = "CURSOR_BIN" + label = "Cursor agent CLI" + merged = _merged_env(env) + path_value = None if merged is None else merged.get("PATH") + resolved = shutil.which(command, path=path_value) + if resolved: + return resolved + raise RuntimeError( + f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH." + ) + + def agent( prompt, cwd=None, @@ -129,6 +151,7 @@ def __call__(self, prompt): def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) if backend == "codex": return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index e6867a7..818cea8 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -21,7 +21,7 @@ import fcntl -from .agent import Agent +from .agent import Agent, _ensure_backend_available, _resolve_backend from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" @@ -194,9 +194,14 @@ def start_agent( raise ValueError("heartbeat_minutes must be >= 0") home = _resolve_home(home) - host = hostname or current_hostname() + local_host = current_hostname() + host = hostname or local_host now = now or utc_now() _ensure_home(home) + backend_name = _resolve_backend(backend) + session_env = _capture_env() + if host == local_host: + _ensure_backend_available(backend_name, session_env) agent_id = uuid.uuid4().hex agent_dir = _agent_dir(home, agent_id) @@ -216,11 +221,11 @@ def start_agent( session = { "thread_id": "", "rollout_path": "", - "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"), + "backend": backend_name, "yolo": bool(yolo), "flags": flags or "", "cwd": cwd, - "env": _capture_env(), + "env": session_env, "pending_messages": [], } agent_name = _choose_name(home, prompt, name) @@ -673,15 +678,45 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No } -def cron_installed(home=None, hostname=None): - """Return whether this home and host have an installed scheduler hook.""" +def cron_status(home=None, hostname=None): + """Return whether this home and host have a runnable scheduler hook.""" home = _resolve_home(home) host = hostname or current_hostname() tag = _cron_tag(home, host) wrapper = home / "bin" / "agent-tick" crontab = _read_crontab() - installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) - return installed and wrapper.exists() + configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) + status = { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "configured": configured, + "healthy": False, + "reason": "", + } + if not configured: + status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME." + return status + if not wrapper.exists(): + status["reason"] = "Scheduler wrapper is missing." + return status + if not wrapper.is_file(): + status["reason"] = "Scheduler wrapper path is not a file." + return status + if not os.access(wrapper, os.X_OK): + status["reason"] = "Scheduler wrapper is not executable." + return status + reason = _check_tick_wrapper(wrapper) + if reason: + status["reason"] = reason + return status + status["healthy"] = True + return status + + +def cron_installed(home=None, hostname=None): + """Return whether this home and host have an installed scheduler hook.""" + return cron_status(home, hostname)["healthy"] def uninstall_cron(home=None, hostname=None): @@ -757,6 +792,74 @@ def render_cron_line(home=None, hostname=None): return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }" +def _check_tick_wrapper(wrapper): + try: + text = wrapper.read_text(encoding="utf-8") + except OSError as exc: + return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}." + env, env_error = _wrapper_env(text) + if env_error: + return env_error + command = _wrapper_exec_command(text) + if not command: + return "Scheduler wrapper is missing its exec command." + try: + argv = shlex.split(command) + except ValueError as exc: + return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}." + if not argv: + return "Scheduler wrapper exec command is empty." + if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi": + check = [argv[0], "-c", "import codexapi"] + label = f"Wrapper python {argv[0]!r} cannot import codexapi." + else: + check = [argv[0], "--version"] + label = f"Wrapper command {argv[0]!r} is not runnable." + try: + result = subprocess.run( + check, + capture_output=True, + text=True, + env=env, + timeout=10, + ) + except OSError as exc: + return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}" + except subprocess.TimeoutExpired: + return f"{label} Timed out while checking it." + if result.returncode == 0: + return "" + detail = _single_line((result.stderr or result.stdout or "").strip()) + if detail: + return f"{label} {detail}" + return label + + +def _wrapper_env(text): + env = dict(os.environ) + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line.startswith("export "): + continue + key, sep, raw_value = line[7:].partition("=") + if not sep: + continue + try: + parts = shlex.split(raw_value) + except ValueError as exc: + return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}." + env[key] = parts[0] if parts else "" + return env, "" + + +def _wrapper_exec_command(text): + for raw_line in text.splitlines(): + line = raw_line.strip() + if line.startswith("exec "): + return line[5:].strip() + return "" + + def _tick_agent(agent_dir, now, runner): meta = _read_json(agent_dir / "meta.json") state = _read_json(agent_dir / "state.json") diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index bf2a5c1..a1ebce9 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _CURSOR_BIN, + _ensure_backend_available, _event_usage, _merged_env, _normalize_usage, @@ -90,6 +91,7 @@ def start( raise ValueError("prompt must be a non-empty string") backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) command = _build_command(backend, cwd, yolo, flags) process = subprocess.Popen( command, diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 77d97ca..1e6c9c6 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -18,7 +18,7 @@ from .agents import ( codexapi_home, control_agent, - cron_installed as agent_cron_installed, + cron_status as agent_cron_status, current_hostname, delete_agent as delete_managed_agent, install_cron as install_agent_cron, @@ -263,7 +263,7 @@ def _agent_install_cron_command(): def _warn_agent_scheduler_missing(): try: - installed = agent_cron_installed() + status = agent_cron_status() except Exception as exc: print( "Warning: could not verify whether the codexapi agent scheduler hook is installed.", @@ -272,7 +272,16 @@ def _warn_agent_scheduler_missing(): print(f"Reason: {exc}", file=sys.stderr) print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) return - if installed: + if status["healthy"]: + return + if status["configured"]: + print( + "Warning: the codexapi agent scheduler hook is installed but not runnable for this CODEXAPI_HOME.", + file=sys.stderr, + ) + if status["reason"]: + print(f"Reason: {status['reason']}", file=sys.stderr) + print(f"Reinstall it with: {_agent_install_cron_command()}", file=sys.stderr) return print( "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. " @@ -2016,18 +2025,21 @@ def main(argv=None): raise SystemExit(2) if args.agent_command == "start": prompt = _read_prompt(args.prompt) - result = start_managed_agent( - prompt, - args.cwd, - args.name, - args.created_by, - args.parent, - args.stop_policy, - args.heartbeat_minutes, - args.backend, - args.yolo, - args.flags, - ) + try: + result = start_managed_agent( + prompt, + args.cwd, + args.name, + args.created_by, + args.parent, + args.stop_policy, + args.heartbeat_minutes, + args.backend, + args.yolo, + args.flags, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from None result["waited"] = bool(args.wait) if args.wait: result["nudge"] = nudge_agent(result["id"], wait=True) diff --git a/tests/test_agents.py b/tests/test_agents.py index 0dc9aa1..91ecbbe 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -23,6 +23,8 @@ _remove_cron_line, _upsert_cron_line, control_agent, + cron_installed, + cron_status, delete_agent, format_utc, install_cron, @@ -776,6 +778,31 @@ def fake_write(text): self.assertFalse(result["changed"]) self.assertEqual(writes, []) + def test_cron_status_reports_broken_wrapper_python(self): + with _temp_home() as home: + write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + hostname="host-a", + ) + crontab = render_cron_line(home=home, hostname="host-a") + "\n" + with patch("codexapi.agents._read_crontab", return_value=crontab): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["/tmp/venv/bin/python", "-c", "import codexapi"], + 1, + stdout="", + stderr="ModuleNotFoundError: No module named 'codexapi'\n", + ), + ): + status = cron_status(home=home, hostname="host-a") + self.assertTrue(status["configured"]) + self.assertFalse(status["healthy"]) + self.assertIn("cannot import codexapi", status["reason"]) + self.assertFalse(cron_installed(home=home, hostname="host-a")) + def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self): writes = [] @@ -1206,9 +1233,13 @@ def test_cli_start_warns_when_cron_missing(self): with _temp_home() as home: output = io.StringIO() errors = io.StringIO() - with patch("codexapi.cli.agent_cron_installed", return_value=False): - with redirect_stdout(output), redirect_stderr(errors): - cli_main(["agent", "start", "Handle messages."]) + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) payload = json.loads(output.getvalue()) self.assertFalse(payload["waited"]) warning = errors.getvalue() @@ -1216,6 +1247,41 @@ def test_cli_start_warns_when_cron_missing(self): self.assertIn(str(home), warning) self.assertIn("codexapi agent install-cron", warning) + def test_cli_start_warns_when_scheduler_is_broken(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={ + "configured": True, + "healthy": False, + "reason": "Wrapper python '/tmp/venv/bin/python' cannot import codexapi.", + }, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("installed but not runnable", warning) + self.assertIn("cannot import codexapi", warning) + self.assertIn("Reinstall it with", warning) + + def test_cli_start_fails_fast_when_backend_is_missing(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch( + "codexapi.agents._ensure_backend_available", + side_effect=RuntimeError("Codex CLI not found: 'codex'."), + ): + with redirect_stdout(output), redirect_stderr(errors): + with self.assertRaises(SystemExit) as exc: + cli_main(["agent", "start", "Handle messages."]) + self.assertEqual(str(exc.exception), "Codex CLI not found: 'codex'.") + def test_cli_send_queues_by_default(self): with _temp_home(): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): From a5f2bc09e54a8db7448bd1d976ef5a9bca8c46dc Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 24 Mar 2026 12:18:30 +0000 Subject: [PATCH 26/31] Reframe durable agent ownership and memory --- README.md | 2 +- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 41 ++++++++++++++----- src/codexapi/lead.py | 85 ++++------------------------------------ 5 files changed, 42 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 50b7827..c75ebf1 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ stops. Lead mode also uses a leadbook file as the agent's working page. By default this is `LEADBOOK.md` in the working directory. The leadbook content is injected into -each check-in prompt and must be updated before the agent responds. Use +each check-in prompt so the agent can keep its working picture current. Use `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable. Use `-f/--prompt-file` to read the prompt from a file. If the leadbook does not exist, lead creates it with a template. diff --git a/pyproject.toml b/pyproject.toml index 587fec8..bb6b26c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.5" +version = "0.12.6" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 6fb0d9a..b163b46 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.5" +__version__ = "0.12.6" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 818cea8..ae2214b 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -26,11 +26,18 @@ _DEFAULT_HOME = "~/.codexapi" _AGENT_PROMPT = ( - "You are a long-term codexapi agent resuming stewardship of an ongoing job. " - "This loop exists to extend your reach, not to confine you. Be independent, " - "practical, and responsible for results. Maintain the agentbook as your durable " - "working memory: preserve the goal, note durable guidance, update your current " - "picture of the work, and record what changed. Queued messages may contain new " + "You are an independent codexapi agent given ownership of achieving a user's " + "goal and authority to act in order to do so. Part of this responsibility is " + "making sure you understand and stay aligned with the user's intent, even when " + "they are imprecise. If clarity is lacking, it is your responsibility to seek " + "it or to make reasonable assumptions and notify the user of them. Maintain " + "the agentbook as your durable working memory: preserve the goal, note durable " + "guidance, and keep your current picture of the work accurate and useful. This " + "harness gives you continuity across long periods of time and multiple " + "conversation turns; use that continuity to keep orienting toward the goal, " + "maintain context, and make real-world progress. If reality is not moving, " + "treat that as evidence and reconsider your frame, assumptions, or ownership " + "rather than merely repeating the same report. Queued messages may contain new " "goals, standing guidance, tactical requests, or useful facts; use judgment to " "decide what is durable. Use codexapi task or codexapi science when you want a " "separate coding worker. If you need the user's attention, put a short message " @@ -70,12 +77,21 @@ def _agentbook_template(prompt): Overall goal: - -Current plan: -- +Current picture: +- + +What is moving: +- + +What is not moving: +- Active tasks: - +Assumptions / ownership: +- + Unexpected developments: - @@ -88,8 +104,8 @@ def _agentbook_template(prompt): Risks / watchpoints: - -Next wake: -- +Next decisive action: +- """ @@ -1106,7 +1122,12 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir): "", f"Working directory: {meta['cwd']}", f"Agentbook path: {book_path}", - "Append a dated note to the agentbook before you respond.", + "Update the agentbook before you respond. Add or revise a dated note when " + "something durable changed, when you corrected your picture, or when an " + "assumption needs to be made explicit.", + "If little has changed across wakes, treat that as evidence about the " + "situation and reconsider your frame or next action instead of padding the " + "book.", "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.", ] if _include_full_goal_prompt(state, session): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index bca4221..1918dc9 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -20,9 +20,10 @@ _WELCOME_PROMPT = ( "Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. " - "This loop exists to extend your reach, not to restrict you. Your job is to interpret the intent behind the " - "goals, act decisively, and keep momentum. If progress is possible, take it. If you are blocked, name the " - "blocker and the next best action to remove it.\n" + "This loop exists to extend your reach, not to restrict you. Your job is to understand the real situation, " + "interpret the intent behind the goals, and move reality toward them. If the world is not moving, treat that " + "as evidence and reconsider your frame rather than merely reporting stasis. If progress is possible, take it. " + "If you are blocked, name the blocker and the next best action to remove it.\n" "The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or " "conflicting. You are responsible for results.\n" "Please follow the instructions completely and take all the actions you deem useful at the current time before " @@ -39,9 +40,10 @@ "To stop this lead loop, set continue to false." ) _LEADBOOK_INSTRUCTIONS = ( - "Update the leadbook before responding. Append a new dated entry each check-in. " - "This is your working page—where you think, probe, decide, and record the path taken. " - "Capture the process of decision-making, not just the outcome." + "Update the leadbook before responding. Add or revise dated notes when your picture, " + "assumptions, or decisions changed. This is your working page—where you think, probe, " + "decide, and reframe the work when needed. Keep it useful; do not pad it with diary " + "entries just to satisfy the loop." ) _LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes @@ -157,38 +159,6 @@ def lead( "Agent was unable to provide valid JSON output after retry.\n" + details ) from None - if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot): - retry_prompt = _leadbook_retry_prompt( - prompt, tick, leadbook_path, leadbook_snapshot["text"], output - ) - leadbook_retry_output = session(retry_prompt) - try: - result = _parse_status(leadbook_retry_output) - except ValueError as exc: - retry_prompt = _json_retry_prompt( - prompt, tick, str(exc), leadbook_retry_output - ) - json_retry_output = session(retry_prompt) - try: - result = _parse_status(json_retry_output) - except ValueError as exc2: - details = _format_json_double_failure( - str(exc), - leadbook_retry_output, - str(exc2), - json_retry_output, - ) - pushover.send(title, f"Lead stopped (invalid JSON).\n{details}") - raise RuntimeError( - "Agent was unable to provide valid JSON output after retry.\n" - + details - ) from None - if not _leadbook_changed(leadbook_path, leadbook_snapshot): - details = _format_leadbook_failure(leadbook_path, output) - pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}") - raise RuntimeError( - "Leadbook was not updated after retry.\n" + details - ) from None last_result = result _print_status(now, elapsed, tick, result) @@ -314,26 +284,6 @@ def _format_stop_message(tick, now, result): return header -def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): - snippet = _snippet(output, 600) - lines = [ - f"Your last message (check-in {tick}) did not update the leadbook.", - f"Leadbook path: {path}", - "", - "Here is your previous output (truncated):", - snippet, - "", - "Please update the leadbook and then respond with JSON only.", - "Return a fresh status update in the required JSON format.", - "If you want to ask the user a question, put it in comments.", - "", - _leadbook_block(path, leadbook), - "", - _JSON_INSTRUCTIONS, - ] - return "\n".join(lines).strip() - - def _leadbook_block(path, leadbook): if not path: return "" @@ -385,29 +335,10 @@ def _snapshot_leadbook(path): return {"hash": _hash_text(text), "text": text} -def _leadbook_changed(path, snapshot): - if not path: - return True - current = _snapshot_leadbook(path) - return current["hash"] != snapshot["hash"] - - def _hash_text(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() -def _format_leadbook_failure(path, output): - snippet = _snippet(output, 600) - return "\n".join( - [ - f"Leadbook path: {path}", - "", - "Last output (truncated):", - snippet, - ] - ).strip() - - def _format_json_failure(error, output): snippet = _snippet(output, 600) return "\n".join( From 5e284ab85ee7f57f1b21ed0659e78497fd7a262d Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 15 Apr 2026 10:58:08 +0200 Subject: [PATCH 27/31] Release v0.12.7 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agents.py | 70 +++++++++++++++++++++++++++++----------- tests/test_agents.py | 27 ++++++++++++++++ 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb6b26c..b5aec60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.6" +version = "0.12.7" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index b163b46..1c254e3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.6" +__version__ = "0.12.7" diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index ae2214b..00e16a9 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -25,25 +25,35 @@ from .pushover import Pushover _DEFAULT_HOME = "~/.codexapi" -_AGENT_PROMPT = ( - "You are an independent codexapi agent given ownership of achieving a user's " - "goal and authority to act in order to do so. Part of this responsibility is " - "making sure you understand and stay aligned with the user's intent, even when " - "they are imprecise. If clarity is lacking, it is your responsibility to seek " - "it or to make reasonable assumptions and notify the user of them. Maintain " - "the agentbook as your durable working memory: preserve the goal, note durable " - "guidance, and keep your current picture of the work accurate and useful. This " - "harness gives you continuity across long periods of time and multiple " - "conversation turns; use that continuity to keep orienting toward the goal, " +_FIRST_WAKE_PROMPT = ( + "You are an independent codexapi agent starting this job. Work from the " + "instructions, current repository state, and agentbook. Do not assume prior " + "progress unless it is shown here. " +) +_CONTINUATION_PROMPT = ( + "You are an independent codexapi agent continuing this job. Use the " + "agentbook and harness facts as the source of truth for prior progress. Do " + "not invent missing history. " +) +_AGENT_PROMPT_TAIL = ( + "You are given ownership of achieving a user's goal and authority to act in " + "order to do so. Part of this responsibility is making sure you understand " + "and stay aligned with the user's intent, even when they are imprecise. If " + "clarity is lacking, it is your responsibility to seek it or to make " + "reasonable assumptions and notify the user of them. Maintain the agentbook " + "as your durable working memory: preserve the goal, note durable guidance, " + "and keep your current picture of the work accurate and useful. This harness " + "can carry work across long periods of time and multiple conversation turns; " + "when prior context exists, use it to keep orienting toward the goal, " "maintain context, and make real-world progress. If reality is not moving, " "treat that as evidence and reconsider your frame, assumptions, or ownership " - "rather than merely repeating the same report. Queued messages may contain new " - "goals, standing guidance, tactical requests, or useful facts; use judgment to " - "decide what is durable. Use codexapi task or codexapi science when you want a " - "separate coding worker. If you need the user's attention, put a short message " - "in the reply field. Put a short first-person turn summary in the update field. " - "If something is urgent and should send Pushover, put it in the notify field. " - "Respond with JSON only." + "rather than merely repeating the same report. Queued messages may contain " + "new goals, standing guidance, tactical requests, or useful facts; use " + "judgment to decide what is durable. Use codexapi task or codexapi science " + "when you want a separate coding worker. If you need the user's attention, " + "put a short message in the reply field. Put a short first-person turn " + "summary in the update field. If something is urgent and should send " + "Pushover, put it in the notify field. Respond with JSON only." ) _AGENT_JSON = ( "Respond with JSON only (no markdown/backticks/extra text).\n" @@ -1112,9 +1122,11 @@ def _parse_agent_response(output): def _build_wake_prompt(meta, state, session, now, commands, agent_dir): messages = session.get("pending_messages") or [] book_path = agent_dir / "AGENTBOOK.md" + wake_mode = _wake_mode(state, session) lines = [ - _AGENT_PROMPT, + _agent_prompt(wake_mode), "", + f"Wake mode: {wake_mode.replace('_', ' ')}", f"Current UTC time: {format_utc(now)}", f"Agent name: {meta['name']}", f"Stop policy: {meta['stop_policy']}", @@ -1670,6 +1682,28 @@ def _include_full_goal_prompt(state, session): return not ((session.get("thread_id") or state.get("thread_id") or "").strip()) +def _wake_mode(state, session): + markers = ( + state.get("last_wake_at"), + state.get("last_success_at"), + state.get("reply"), + state.get("update"), + state.get("last_error"), + state.get("thread_id"), + session.get("thread_id"), + ) + for value in markers: + if (value or "").strip(): + return "continuation" + return "first_wake" + + +def _agent_prompt(wake_mode): + if wake_mode == "continuation": + return _CONTINUATION_PROMPT + _AGENT_PROMPT_TAIL + return _FIRST_WAKE_PROMPT + _AGENT_PROMPT_TAIL + + def _wake_facts(state): facts = [] previous_status = (state.get("activity") or "").strip() diff --git a/tests/test_agents.py b/tests/test_agents.py index 91ecbbe..51a8fb9 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -189,6 +189,7 @@ def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): agent_dir, ) self.assertIn("Agentbook (header + latest notes):", prompt) + self.assertIn("Wake mode: continuation", prompt) self.assertIn("## Purpose", prompt) self.assertIn("Hold the whole.", prompt) self.assertIn("Watch for the real issue.", prompt) @@ -196,8 +197,10 @@ def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): self.assertIn("Previous status: Watching", prompt) self.assertIn("Previous update: Still narrowing the field.", prompt) self.assertIn("[... older notes omitted ...]", prompt) + self.assertIn("You are an independent codexapi agent continuing this job.", prompt) self.assertNotIn("Original instructions:", prompt) self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt) + self.assertNotIn("resuming stewardship", prompt) def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): with _temp_home() as home: @@ -235,9 +238,33 @@ def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): self.assertIn("The durable agentbook header was restored automatically on wake", repaired) self.assertIn("Legacy note about the real issue.", repaired) self.assertIn("## Purpose", prompt) + self.assertIn("Wake mode: continuation", prompt) self.assertIn("Keep the true goal in view.", prompt) self.assertNotIn("Original instructions:", prompt) + def test_build_wake_prompt_marks_first_wake_without_prior_history(self): + with _temp_home() as home: + agent = start_agent("Start from what is actually shown.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + + self.assertIn("Wake mode: first wake", prompt) + self.assertIn("You are an independent codexapi agent starting this job.", prompt) + self.assertIn("Do not assume prior progress unless it is shown here.", prompt) + self.assertIn("Original instructions:", prompt) + self.assertNotIn("resuming stewardship", prompt) + def test_leadbook_block_shows_header_and_latest_notes(self): leadbook = "\n".join( [ From dd8f41f99742f14d423ca53d4d2613928c33f245 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Wed, 15 Apr 2026 11:26:52 +0200 Subject: [PATCH 28/31] Release v0.12.8 --- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/cli.py | 145 ++++++++++++++++++++++++--------------- tests/test_agents.py | 24 +++++++ 4 files changed, 116 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5aec60..d095bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.7" +version = "0.12.8" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 1c254e3..a29d7ce 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.7" +__version__ = "0.12.8" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 1e6c9c6..11e2a9d 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -106,6 +106,14 @@ _FOREACH_STATUS_MARKERS = {"⏳", "✅", "❌"} +def _add_subparser(subparsers, name, help_text, **kwargs): + parser_kwargs = dict(kwargs) + parser_kwargs["help"] = help_text + if help_text is not argparse.SUPPRESS: + parser_kwargs.setdefault("description", help_text) + return subparsers.add_parser(name, **parser_kwargs) + + def _read_prompt(prompt): if prompt and prompt != "-": return prompt @@ -1446,9 +1454,10 @@ def main(argv=None): ) subparsers = parser.add_subparsers(dest="command") - run_parser = subparsers.add_parser( + run_parser = _add_subparser( + subparsers, "run", - help="Run an agent prompt.", + "Run an agent prompt.", ) run_parser.add_argument( "prompt", @@ -1477,9 +1486,10 @@ def main(argv=None): help="Return all agent messages joined together (Codex only).", ) - lead_parser = subparsers.add_parser( + lead_parser = _add_subparser( + subparsers, "lead", - help="Periodically check in to lead long-running work.", + "Periodically check in to lead long-running work.", ) lead_parser.add_argument( "minutes", @@ -1531,15 +1541,17 @@ def main(argv=None): help="Print the current thread id to stderr after running.", ) - agent_parser = subparsers.add_parser( + agent_parser = _add_subparser( + subparsers, "agent", - help="Manage durable long-running agents.", + "Manage durable long-running agents.", ) agent_subparsers = agent_parser.add_subparsers(dest="agent_command") - agent_start = agent_subparsers.add_parser( + agent_start = _add_subparser( + agent_subparsers, "start", - help="Create a durable agent and return immediately unless --wait is set.", + "Create a durable agent and return immediately unless --wait is set.", ) agent_start.add_argument( "prompt", @@ -1589,30 +1601,35 @@ def main(argv=None): help="Wait for the first local wake to finish instead of just scheduling it.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "list", - help="List durable agents in this CODEXAPI_HOME.", + "List durable agents in this CODEXAPI_HOME.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "whoami", - help="Show the effective host and CODEXAPI_HOME for agents.", + "Show the effective host and CODEXAPI_HOME for agents.", ) - agent_run = agent_subparsers.add_parser( + agent_run = _add_subparser( + agent_subparsers, "run", - help=argparse.SUPPRESS, + argparse.SUPPRESS, ) agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) - agent_show = agent_subparsers.add_parser( + agent_show = _add_subparser( + agent_subparsers, "show", - help="Show one durable agent.", + "Show one durable agent.", ) agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") - agent_status = agent_subparsers.add_parser( + agent_status = _add_subparser( + agent_subparsers, "status", - help="Show the latest rollout turn for one durable agent.", + "Show the latest rollout turn for one durable agent.", ) agent_status.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_status.add_argument( @@ -1623,9 +1640,10 @@ def main(argv=None): help="Include verbose tool actions from the latest turn.", ) - agent_read = agent_subparsers.add_parser( + agent_read = _add_subparser( + agent_subparsers, "read", - help="Read recent visible communication for one agent.", + "Read recent visible communication for one agent.", ) agent_read.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_read.add_argument( @@ -1635,15 +1653,17 @@ def main(argv=None): help="Maximum number of items to show (default: 10).", ) - agent_book = agent_subparsers.add_parser( + agent_book = _add_subparser( + agent_subparsers, "book", - help="Show the current agentbook for one agent.", + "Show the current agentbook for one agent.", ) agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") - agent_send = agent_subparsers.add_parser( + agent_send = _add_subparser( + agent_subparsers, "send", - help="Queue a message for an agent and return immediately unless --wait is set.", + "Queue a message for an agent and return immediately unless --wait is set.", ) agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_send.add_argument("message", help="Message to queue.") @@ -1660,7 +1680,7 @@ def main(argv=None): ("resume", "Resume a paused agent and return immediately unless --wait is set."), ("cancel", "Cancel an agent."), ): - subparser = agent_subparsers.add_parser(subcommand, help=help_text) + subparser = _add_subparser(agent_subparsers, subcommand, help_text) subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") subparser.add_argument("--author", help="Author label for the command.") if subcommand in ("wake", "resume"): @@ -1670,9 +1690,10 @@ def main(argv=None): help="Wait for a local wake after queueing the command.", ) - agent_recover = agent_subparsers.add_parser( + agent_recover = _add_subparser( + agent_subparsers, "recover", - help="Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", + "Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", ) agent_recover.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_recover.add_argument( @@ -1681,9 +1702,10 @@ def main(argv=None): help="Wait for a local wake after recovery.", ) - agent_set_heartbeat = agent_subparsers.add_parser( + agent_set_heartbeat = _add_subparser( + agent_subparsers, "set-heartbeat", - help="Update the heartbeat interval for one durable agent.", + "Update the heartbeat interval for one durable agent.", ) agent_set_heartbeat.add_argument( "agent_ref", @@ -1695,9 +1717,10 @@ def main(argv=None): help="Heartbeat interval in minutes.", ) - agent_delete = agent_subparsers.add_parser( + agent_delete = _add_subparser( + agent_subparsers, "delete", - help="Delete one durable agent and its files.", + "Delete one durable agent and its files.", ) agent_delete.add_argument("agent_ref", help="Agent id, unique prefix, or name.") agent_delete.add_argument( @@ -1706,27 +1729,32 @@ def main(argv=None): help="Delete even when the agent is not terminal or still has children.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "tick", - help="Process due agents for the current host.", + "Process due agents for the current host.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "install-cron", - help="Install or update the cron entry for this CODEXAPI_HOME.", + "Install or update the cron entry for this CODEXAPI_HOME.", ) - agent_subparsers.add_parser( + _add_subparser( + agent_subparsers, "uninstall-cron", - help="Remove the cron entry for this CODEXAPI_HOME.", + "Remove the cron entry for this CODEXAPI_HOME.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "tick", - help="Run one full background tick.", + "Run one full background tick.", ) - task_parser = subparsers.add_parser( + task_parser = _add_subparser( + subparsers, "task", - help="Run a task with verification retries.", + "Run a task with verification retries.", ) task_parser.add_argument( "-f", @@ -1806,9 +1834,10 @@ def main(argv=None): help="With -p, keep taking tasks and wait when none are available.", ) - ralph_parser = subparsers.add_parser( + ralph_parser = _add_subparser( + subparsers, "ralph", - help="Run a Ralph loop.", + "Run a Ralph loop.", epilog=ralph_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1864,9 +1893,10 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - science_parser = subparsers.add_parser( + science_parser = _add_subparser( + subparsers, "science", - help="Run a science-mode Ralph loop.", + "Run a science-mode Ralph loop.", epilog=science_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1929,9 +1959,10 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - foreach_parser = subparsers.add_parser( + foreach_parser = _add_subparser( + subparsers, "foreach", - help="Run a task file over a list file.", + "Run a task file over a list file.", ) foreach_parser.add_argument( "list_file", @@ -1974,18 +2005,20 @@ def main(argv=None): help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - create_parser = subparsers.add_parser( + create_parser = _add_subparser( + subparsers, "create", - help="Create a task file template.", + "Create a task file template.", ) create_parser.add_argument( "filename", help="Filename for the new task file.", ) - reset_parser = subparsers.add_parser( + reset_parser = _add_subparser( + subparsers, "reset", - help="Reset project tasks back to Ready.", + "Reset project tasks back to Ready.", ) reset_parser.add_argument( "-p", @@ -2006,13 +2039,15 @@ def main(argv=None): help="Remove any Progress section in the issue body.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "top", - help="Show running Codex sessions.", + "Show running Codex sessions.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "limit", - help="Show Codex rate limits.", + "Show Codex rate limits.", ) args = parser.parse_args(argv) diff --git a/tests/test_agents.py b/tests/test_agents.py index 51a8fb9..5a9c4c8 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -88,6 +88,30 @@ def test_cli_version(self): self.assertEqual(exc.exception.code, 0) self.assertEqual(output.getvalue().strip(), f"codexapi {__version__}") + def test_cli_subcommand_help_shows_one_line_description(self): + cases = ( + ( + ["task", "--help"], + "Run a task with verification retries.", + ), + ( + ["agent", "resume", "--help"], + "Resume a paused agent and return immediately unless --wait is set.", + ), + ( + ["agent", "list", "--help"], + "List durable agents in this CODEXAPI_HOME.", + ), + ) + for argv, expected in cases: + with self.subTest(argv=argv): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(argv) + self.assertEqual(exc.exception.code, 0) + self.assertIn(expected, output.getvalue()) + def test_current_hostname_prefers_override(self): with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): from codexapi.agents import current_hostname From 4fe414bd73363406595df935328bc62520c91da1 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Fri, 24 Apr 2026 00:13:16 +0200 Subject: [PATCH 29/31] Release v0.12.9 --- README.md | 23 +++++++++---- pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 37 ++++++++++++++++++--- src/codexapi/agents.py | 32 +++++++++++++----- src/codexapi/async_agent.py | 32 ++++++++++++++---- src/codexapi/cli.py | 46 +++++++++++++++++++++++++ src/codexapi/foreach.py | 4 +++ src/codexapi/gh_integration.py | 5 ++- src/codexapi/lead.py | 7 +++- src/codexapi/ralph.py | 44 ++++++++++++++---------- src/codexapi/science.py | 25 ++++++++++++-- src/codexapi/task.py | 61 ++++++++++++++++++++++++++-------- src/codexapi/taskfile.py | 3 ++ tests/test_agent_backend.py | 27 ++++++++++++++- tests/test_agents.py | 27 +++++++++++++++ tests/test_async_agent.py | 61 ++++++++++++++++++++++++++++++++++ 17 files changed, 374 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c75ebf1..a351dfb 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ for update in agent.watch(poll_interval=2.0): Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. +Use `fast=True` in Codex API calls, or `--fast` in the CLI, to opt into Codex +fast mode. Normal mode is the default. ## CLI @@ -73,6 +75,7 @@ codexapi --version codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run +codexapi run --fast "Summarize this repo quickly." codexapi run --backend cursor "Summarize this repo." ``` @@ -318,7 +321,7 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False) -> str` Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. @@ -329,8 +332,9 @@ items are filtered out. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -343,9 +347,10 @@ the same conversation and returns only the agent's message. and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. -### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None) -> dict` +### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None, fast=False) -> dict` Runs a long-lived agent session and periodically checks in with the current local time and a reminder of `prompt`. Each check-in expects JSON with keys: @@ -358,7 +363,7 @@ Lead also injects the leadbook content into each prompt. By default it uses path string to override the location. Set `backend="cursor"` (or `CODEXAPI_BACKEND=cursor`) to use Cursor. -### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> str` +### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None, fast=False) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum iterations are reached. @@ -368,14 +373,15 @@ Raises `TaskFailed` when the maximum iterations are reached. - `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None) -> TaskResult` +### `task_result(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None, backend=None, fast=False) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. Arguments mirror `task()` (including hooks). -### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None, fast=False)` Runs an agent task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return @@ -408,7 +414,7 @@ Exception raised by `task()` when iterations are exhausted. - `iterations` (int | None): iterations made when the task failed. - `errors` (str | None): last checker error, if any. -### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None) -> ForeachResult` +### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None, fast=False) -> ForeachResult` Runs a task file over a list of items, updating the list file in place. @@ -419,6 +425,7 @@ Runs a task file over a list of items, updating the list file in place. - `yolo` (bool): pass `--yolo` when true (defaults to true). - `flags` (str | None): extra CLI flags to pass to the agent backend. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). ### `ForeachResult(succeeded, failed, skipped, results)` @@ -433,6 +440,8 @@ Simple result object returned by `foreach()`. - Codex backend uses `codex exec --json` and parses JSONL `agent_message` items. - Codex backend passes `--skip-git-repo-check` so it can run outside a git repo. +- Codex backend defaults to normal mode and passes `features.fast_mode=false`; + `fast=True` / `--fast` also passes `service_tier=fast` and `features.fast_mode=true`. - Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. - `include_thinking=True` only affects Codex; Cursor returns a single result string. - Passes `--yolo` by default (Codex uses `--full-auto` when disabled). diff --git a/pyproject.toml b/pyproject.toml index d095bd5..c99abdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.8" +version = "0.12.9" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index a29d7ce..0a6de5e 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -29,4 +29,4 @@ "task_result", "lead", ] -__version__ = "0.12.8" +__version__ = "0.12.9" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 4fb3e7d..3323677 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -54,6 +54,7 @@ def agent( include_thinking=False, backend=None, env=None, + fast=False, ): """Run a single agent turn and return only the agent's message. @@ -65,12 +66,13 @@ def agent( include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id, _usage = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend, env + prompt, cwd, None, yolo, flags, include_thinking, backend, env, fast ) return message @@ -103,6 +105,7 @@ def __init__( include_thinking=False, backend=None, env=None, + fast=False, ): """Create a new session wrapper. @@ -116,6 +119,7 @@ def __init__( include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. """ self.cwd = cwd self._yolo = yolo @@ -125,6 +129,7 @@ def __init__( self.thread_id = thread_id self._backend = backend self._env = env + self._fast = fast self.last_usage = {} def __call__(self, prompt): @@ -140,6 +145,7 @@ def __call__(self, prompt): self._include_thinking, self._backend, self._env, + self._fast, ) if thread_id: self.thread_id = thread_id @@ -149,15 +155,25 @@ def __call__(self, prompt): return message -def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env): +def _run_agent( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + backend, + env, + fast=False, +): backend = _resolve_backend(backend) _ensure_backend_available(backend, env) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast) return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): +def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast=False): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -171,6 +187,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): command.append("--yolo") else: command.append("--full-auto") + command.extend(_codex_fast_config(fast)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -198,6 +215,18 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env): return _parse_jsonl(result.stdout, include_thinking) +def _codex_fast_config(fast): + """Return Codex config flags for normal or fast mode.""" + if fast: + return [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ] + return ["-c", "features.fast_mode=false"] + + def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" command = [ diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py index 00e16a9..bd91ff7 100644 --- a/src/codexapi/agents.py +++ b/src/codexapi/agents.py @@ -210,6 +210,7 @@ def start_agent( home=None, hostname=None, now=None, + fast=False, ): """Create a durable agent and return its current snapshot.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -250,6 +251,7 @@ def start_agent( "backend": backend_name, "yolo": bool(yolo), "flags": flags or "", + "fast": bool(fast), "cwd": cwd, "env": session_env, "pending_messages": [], @@ -1053,15 +1055,27 @@ def _run_agent_turn(meta, session, prompt, runner=None): raise TypeError("runner must return a dict") return outcome started = utc_now() - worker = Agent( - session.get("cwd") or meta.get("cwd"), - session.get("yolo", True), - session.get("thread_id") or None, - session.get("flags") or None, - include_thinking=False, - backend=session.get("backend") or None, - env=_agent_env(meta, session), - ) + if session.get("fast", False): + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + fast=True, + ) + else: + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + ) message = worker(prompt) usage = worker.last_usage or {} rollout_path = "" diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index a1ebce9..e203057 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _CURSOR_BIN, + _codex_fast_config, _ensure_backend_available, _event_usage, _merged_env, @@ -48,6 +49,7 @@ def __init__( self._lock = threading.Lock() self._stdout_lines: list[str] = [] self._stderr_lines: list[str] = [] + self._errors: list[str] = [] self._messages: list[str] = [] self._thread_id = "" self._rollout_path = "" @@ -85,6 +87,7 @@ def start( backend=None, env=None, name=None, + fast=False, ): """Start a backend subprocess and return an async handle immediately.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -92,7 +95,7 @@ def start( backend = _resolve_backend(backend) _ensure_backend_available(backend, env) - command = _build_command(backend, cwd, yolo, flags) + command = _build_command(backend, cwd, yolo, flags, fast) process = subprocess.Popen( command, stdin=subprocess.PIPE, @@ -157,6 +160,7 @@ def status(self, include_actions=False) -> dict[str, object]: progress = list(self._progress) tools = list(self._tools) if include_actions else [] stderr_lines = list(self._stderr_lines) + error_lines = list(self._errors) thread_id = self._thread_id rollout_path = self._rollout_path last_event_at = self._last_event_at @@ -168,7 +172,9 @@ def status(self, include_actions=False) -> dict[str, object]: progress=progress, final_output=final_output, stderr_lines=stderr_lines, + error_lines=error_lines, ) + last_error = error_lines[-1] if error_lines else stderr_lines[-1] if stderr_lines else "" return { "id": self.id, "name": self.name, @@ -184,8 +190,9 @@ def status(self, include_actions=False) -> dict[str, object]: "final_output": final_output, "last_event_at": last_event_at, "returncode": returncode, - "last_error": stderr_lines[-1] if stderr_lines else "", + "last_error": last_error, "stderr": "\n".join(stderr_lines), + "errors": error_lines, "messages": messages, "usage": last_usage, } @@ -310,6 +317,16 @@ def _handle_stdout_line(self, line: str) -> None: text = item.get("text") if isinstance(text, str): self._messages.append(text) + elif event.get("type") == "error": + message = event.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) + elif event.get("type") == "turn.failed": + error = event.get("error") or {} + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) def _refresh_rollout(self) -> None: if self.backend != "codex": @@ -359,13 +376,13 @@ def _current_final_output_locked(self) -> str: return self._rollout_final_output -def _build_command(backend, cwd, yolo, flags): +def _build_command(backend, cwd, yolo, flags, fast=False): if backend == "codex": - return _build_codex_command(cwd, yolo, flags) + return _build_codex_command(cwd, yolo, flags, fast) return _build_cursor_command(cwd, yolo, flags) -def _build_codex_command(cwd, yolo, flags): +def _build_codex_command(cwd, yolo, flags, fast=False): command = [ _CODEX_BIN, "exec", @@ -378,6 +395,7 @@ def _build_codex_command(cwd, yolo, flags): command.append("--yolo") else: command.append("--full-auto") + command.extend(_codex_fast_config(fast)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -425,9 +443,11 @@ def _status_text(returncode, canceled): return "error" -def _activity_text(status, progress, final_output, stderr_lines): +def _activity_text(status, progress, final_output, stderr_lines, error_lines=None): if progress: return progress[-1] + if status == "error" and error_lines: + return error_lines[-1] if status == "error" and stderr_lines: return stderr_lines[-1] if final_output: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 11e2a9d..830f0ab 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1470,6 +1470,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + run_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) run_parser.add_argument( "--no-yolo", action="store_false", @@ -1512,6 +1517,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + lead_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) lead_parser.add_argument( "--leadbook", help="Path to the leadbook file (default: LEADBOOK.md in cwd).", @@ -1585,6 +1595,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + agent_start.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) agent_start.add_argument( "--no-yolo", action="store_false", @@ -1813,6 +1828,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + task_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) task_parser.add_argument( "--no-yolo", action="store_false", @@ -1882,6 +1902,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + ralph_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) ralph_parser.add_argument( "--no-yolo", action="store_false", @@ -1948,6 +1973,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + science_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) science_parser.add_argument( "--no-yolo", action="store_false", @@ -1994,6 +2024,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + foreach_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) foreach_parser.add_argument( "--no-yolo", action="store_false", @@ -2072,6 +2107,7 @@ def main(argv=None): args.backend, args.yolo, args.flags, + fast=args.fast, ) except RuntimeError as exc: raise SystemExit(str(exc)) from None @@ -2215,6 +2251,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) if result.failed: raise SystemExit(1) @@ -2285,6 +2322,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: print(str(exc), file=sys.stderr) @@ -2314,6 +2352,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: raise SystemExit(str(exc)) from None @@ -2350,6 +2389,7 @@ def main(argv=None): thread_id=None, flags=args.flags, backend=args.backend, + fast=args.fast, ) result = task_runner(progress=not args.quiet) if not result.success: @@ -2384,6 +2424,7 @@ def main(argv=None): args.completion_promise, args.ralph_fresh, args.backend, + args.fast, )() return if args.command == "science": @@ -2400,6 +2441,7 @@ def main(argv=None): args.ralph_fresh, max_duration_seconds, args.backend, + args.fast, )() return if args.command == "lead": @@ -2417,6 +2459,7 @@ def main(argv=None): args.flags, leadbook, args.backend, + args.fast, ) except KeyboardInterrupt: raise SystemExit(130) @@ -2453,6 +2496,7 @@ def main(argv=None): args.flags, not args.quiet, backend=args.backend, + fast=args.fast, ) except TaskFailed as exc: exit_code = 1 @@ -2466,6 +2510,7 @@ def main(argv=None): args.flags, include_thinking=args.include_thinking, backend=args.backend, + fast=args.fast, ) message = session(prompt) if args.print_thread_id: @@ -2478,6 +2523,7 @@ def main(argv=None): args.flags, args.include_thinking, args.backend, + fast=args.fast, ) if message is not None: diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index c7cc6a0..b78676a 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -42,6 +42,7 @@ def foreach( yolo=True, flags=None, backend=None, + fast=False, ): """Run a task file over each item in list_file and update the file.""" lines, ends_with_newline = _read_lines(list_file) @@ -77,6 +78,7 @@ def foreach( yolo, flags, backend, + fast, counts, results, progress, @@ -174,6 +176,7 @@ def _run_item( yolo, flags, backend, + fast, counts, results, progress, @@ -199,6 +202,7 @@ def _run_item( thread_id=None, flags=flags, backend=backend, + fast=fast, ) max_iterations = task.max_iterations result = task() diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index f7563cd..8731f76 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -235,8 +235,9 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): - super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend) + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend, fast) self.issue = issue self.project = project self._progress_updates = True @@ -310,6 +311,7 @@ def __init__( yolo=True, flags=None, backend=None, + fast=False, ): task_map = _task_file_map(task_files) self.project = Project(project, name, has_label=list(task_map)) @@ -340,6 +342,7 @@ def __init__( None, flags, backend, + fast, ) def __call__(self, progress=False): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 1918dc9..a7c28b5 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -83,6 +83,7 @@ def lead( flags=None, leadbook=None, backend=None, + fast=False, ): """Run a periodic lead loop. @@ -94,6 +95,7 @@ def lead( flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The last parsed JSON status object. @@ -106,7 +108,10 @@ def lead( raise ValueError("prompt must be a non-empty string") interval = minutes * 60 - session = Agent(cwd, yolo, None, flags, backend=backend) + if fast: + session = Agent(cwd, yolo, None, flags, backend=backend, fast=True) + else: + session = Agent(cwd, yolo, None, flags, backend=backend) pushover = Pushover() pushover.ensure_ready() title = _format_title(prompt) diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 205e5af..c9aad33 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -25,6 +25,7 @@ def __init__( completion_promise=None, fresh=True, backend=None, + fast=False, ): if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -43,6 +44,7 @@ def __init__( self.completion_promise = completion_promise self.fresh = fresh self.backend = backend + self.fast = fast self.include_thinking = True def hook_before_loop(self): @@ -159,25 +161,9 @@ def __call__(self): self.hook_before_iteration(iteration) if self.fresh: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() elif runner is None: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() prompt = self.build_prompt(iteration) stopped = False @@ -250,6 +236,28 @@ def __call__(self): _cleanup_state(state_path) self.hook_after_loop(last_message, stop_reason) + def _new_agent(self): + if self.fast: + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + fast=True, + ) + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + ) + def cancel_ralph_loop(cwd=None): """Cancel the Ralph loop by removing the state file.""" diff --git a/src/codexapi/science.py b/src/codexapi/science.py index b976be8..21d5583 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -103,6 +103,7 @@ def __init__( fresh=True, max_duration_seconds=0, backend=None, + fast=False, ): if max_duration_seconds < 0: raise ValueError("max_duration_seconds must be >= 0") @@ -118,6 +119,7 @@ def __init__( completion_promise, fresh, backend, + fast, ) self.include_thinking = True self._prompt_a = prompt_a @@ -132,6 +134,7 @@ def __init__( self._duration_limit_hit = False self._last_iteration = 0 self._backend = backend + self._fast = fast def hook_before_loop(self): super().hook_before_loop() @@ -199,7 +202,7 @@ def _append_logbook(self, iteration, message): def _extract_and_notify(self, message): prompt = _build_metrics_prompt(self._task, message, self._best_metrics) try: - output = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + output = self._agent(prompt) except Exception as exc: _warn(f"Metrics extraction failed: {exc}") return @@ -222,7 +225,7 @@ def _build_run_title(self): ] ) try: - title = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + title = self._agent(prompt) except Exception: title = "" title = _single_line(title).strip() @@ -230,6 +233,24 @@ def _build_run_title(self): title = _fallback_title(self._task) return title + def _agent(self, prompt): + if self._fast: + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + fast=True, + ) + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + ) + def _mark_duration_stop(self, iteration): if self._duration_limit_hit: return diff --git a/src/codexapi/task.py b/src/codexapi/task.py index d54d731..436425a 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -192,6 +192,7 @@ def estimate( flags, previous_total, backend=None, + fast=False, ): estimate_prompt = _build_estimate_prompt( prompt, @@ -199,10 +200,16 @@ def estimate( check_output or "", previous_total, ) - output = agent(estimate_prompt, cwd, yolo, flags, backend=backend) + output = _call_agent(estimate_prompt, cwd, yolo, flags, backend, fast) return _estimate_result(output) +def _call_agent(prompt, cwd, yolo, flags, backend, fast): + if fast: + return agent(prompt, cwd, yolo, flags, backend=backend, fast=True) + return agent(prompt, cwd, yolo, flags, backend=backend) + + def _fix_prompt(error): return ( "Thanks for your work. An automated verifier reported these issues:\n" @@ -258,6 +265,7 @@ def task( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries. @@ -275,6 +283,7 @@ def task( on_success: Optional prompt to run after a successful task. on_failure: Optional prompt to run after a failed task. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The agent's response text when the task succeeds. @@ -295,6 +304,7 @@ def task( on_success, on_failure, backend, + fast, ) if result.success: return result.summary @@ -314,6 +324,7 @@ def task_result( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries and return TaskResult. @@ -344,6 +355,7 @@ def task_result( on_success=on_success_text, on_failure=on_failure_text, backend=backend, + fast=fast, ) return runner(progress=progress) @@ -390,6 +402,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): if max_iterations < 0: raise ValueError("max_iterations must be >= 0") @@ -403,20 +416,32 @@ def __init__( self._yolo = yolo self._flags = flags self._backend = backend + self._fast = fast self._progress_enabled = False self._progress_updates = False self._progress_bar = None self._progress_total = None self._progress_start = None self._pushover = Pushover() - self.agent = Agent( - cwd, - yolo, - thread_id, - flags, - welfare=True, - backend=backend, - ) + if fast: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + fast=True, + ) + else: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + ) def set_up(self): """Clone a repo, set up a directory etc.""" @@ -439,12 +464,13 @@ def check(self, output=None): last_output = output if output is not None else self.last_output last_output = last_output or "" check_prompt = _build_check_prompt(check_text, last_output) - check_output = agent( + check_output = _call_agent( check_prompt, self.cwd, self._yolo, self._flags, - backend=self._backend, + self._backend, + self._fast, ) self.last_check_output = check_output success, reason = _check_result(check_output) @@ -522,6 +548,7 @@ def _estimate_progress(self, agent_output, check_output): self._flags, self._progress_total, backend=self._backend, + fast=self._fast, ), None, ) @@ -696,6 +723,7 @@ def __init__( on_success=None, on_failure=None, backend=None, + fast=False, ): if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") @@ -709,6 +737,7 @@ def __init__( thread_id, flags, backend, + fast, ) self.check_text = check self._set_up = _validate_hook("set_up", set_up) @@ -718,7 +747,14 @@ def __init__( def _run_hook(self, text): if text: - agent(text, self.cwd, self._yolo, self._flags, backend=self._backend) + _call_agent( + text, + self.cwd, + self._yolo, + self._flags, + self._backend, + self._fast, + ) def set_up(self): self._run_hook(self._set_up) @@ -731,4 +767,3 @@ def on_success(self, result): def on_failure(self, result): self._run_hook(self._on_failure) - diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index e9e606b..7aa9071 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -78,6 +78,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): task_def = load_task_file(path) if max_iterations is None: @@ -108,6 +109,7 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) return super().__init__( @@ -123,4 +125,5 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index ae443b3..d54a5fb 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -5,10 +5,35 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from codexapi.agent import _parse_jsonl +from codexapi.agent import _codex_fast_config, _parse_jsonl +from codexapi.async_agent import _build_codex_command class AgentBackendTests(unittest.TestCase): + def test_codex_fast_config_defaults_to_normal_mode(self): + self.assertEqual(_codex_fast_config(False), ["-c", "features.fast_mode=false"]) + + def test_codex_fast_config_enables_fast_mode(self): + self.assertEqual( + _codex_fast_config(True), + [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ], + ) + + def test_async_codex_command_uses_normal_mode_by_default(self): + command = _build_codex_command(None, True, None) + self.assertIn("features.fast_mode=false", command) + self.assertNotIn("service_tier=fast", command) + + def test_async_codex_command_can_enable_fast_mode(self): + command = _build_codex_command(None, True, None, fast=True) + self.assertIn("service_tier=fast", command) + self.assertIn("features.fast_mode=true", command) + def test_parse_jsonl_extracts_last_token_usage(self): output = "\n".join( [ diff --git a/tests/test_agents.py b/tests/test_agents.py index 5a9c4c8..06cb2aa 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -142,6 +142,18 @@ def test_homes_are_isolated(self): second = start_agent("Watch CI failures.", hostname="host-b") self.assertNotEqual(first["id"], second["id"]) + def test_start_agent_defaults_to_normal_mode(self): + with _temp_home(): + started = start_agent("Use normal mode.", hostname="host-a") + shown = show_agent(started["id"]) + self.assertFalse(shown["session"]["fast"]) + + def test_start_agent_can_enable_fast_mode(self): + with _temp_home(): + started = start_agent("Use fast mode.", hostname="host-a", fast=True) + shown = show_agent(started["id"]) + self.assertTrue(shown["session"]["fast"]) + def test_read_agentbook_and_cli_book(self): with _temp_home(): agent = start_agent("Keep notes.", hostname="host-a") @@ -1298,6 +1310,21 @@ def test_cli_start_warns_when_cron_missing(self): self.assertIn(str(home), warning) self.assertIn("codexapi agent install-cron", warning) + def test_cli_start_can_enable_fast_mode(self): + with _temp_home(): + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "--fast", "Handle messages."]) + payload = json.loads(output.getvalue()) + shown = show_agent(payload["id"]) + self.assertTrue(shown["session"]["fast"]) + def test_cli_start_warns_when_scheduler_is_broken(self): output = io.StringIO() errors = io.StringIO() diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py index f1cbc57..f31cc2e 100644 --- a/tests/test_async_agent.py +++ b/tests/test_async_agent.py @@ -153,6 +153,67 @@ def test_async_agent_reports_rollout_progress_and_final_output(self): {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14}, ) + def test_async_agent_reports_codex_json_error_events(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import sys + + sys.stdin.read() + print(json.dumps({"type": "thread.started", "thread_id": "thread-error"}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + print( + json.dumps( + { + "type": "error", + "message": "Reconnecting... 5/5 (model unavailable)", + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.failed", + "error": {"message": "The model `gpt-5.5` does not exist."}, + } + ), + flush=True, + ) + raise SystemExit(1) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-error-test", + ) + final = agent.wait(poll_interval=0.01) + + self.assertEqual(final["status"], "error") + self.assertEqual(final["last_error"], "The model `gpt-5.5` does not exist.") + self.assertIn("model unavailable", final["errors"][0]) + self.assertEqual(final["activity"], "The model `gpt-5.5` does not exist.") + if __name__ == "__main__": unittest.main() From c976e3c3a1e438aad3910abf892ddb7942b4bb1e Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Mon, 27 Apr 2026 13:01:37 +0200 Subject: [PATCH 30/31] Add model and thinking backend overrides --- README.md | 10 ++- pyproject.toml | 2 +- src/codexapi/__init__.py | 5 +- src/codexapi/agent.py | 153 +++++++++++++++++++++++++++++++++--- src/codexapi/async_agent.py | 23 +++--- tests/test_agent_backend.py | 34 +++++++- 6 files changed, 200 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index a351dfb..3408e33 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. Use `fast=True` in Codex API calls, or `--fast` in the CLI, to opt into Codex fast mode. Normal mode is the default. +Use `model="..."` and `thinking="..."` in Codex API calls to override the +backend model and reasoning effort for a run. ## CLI @@ -321,7 +323,7 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False, model=None, thinking=None) -> str` Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. @@ -333,8 +335,10 @@ items are filtered out. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. Codex maps this to `-c model=...`; Cursor maps it to `--model ...`. +- `thinking` (str | None): Codex reasoning effort override, mapped to `-c model_reasoning_effort=...`. -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False, model=None, thinking=None)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. @@ -348,6 +352,8 @@ the same conversation and returns only the agent's message. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. +- `thinking` (str | None): Codex reasoning effort override. For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. ### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None, fast=False) -> dict` diff --git a/pyproject.toml b/pyproject.toml index c99abdf..c290aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.9" +version = "0.12.10" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 0a6de5e..5fc8665 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,6 @@ """Minimal Python API for running agent CLIs.""" -from .agent import Agent, WelfareStop, agent +from .agent import Agent, WelfareStop, agent, build_agent_flags from .async_agent import AsyncAgent from .foreach import ForeachResult, foreach from .pushover import Pushover @@ -15,6 +15,7 @@ "AsyncAgent", "ForeachResult", "Pushover", + "build_agent_flags", "quota_line", "rate_limits", "Ralph", @@ -29,4 +30,4 @@ "task_result", "lead", ] -__version__ = "0.12.9" +__version__ = "0.12.10" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 3323677..d2e9fb8 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -11,6 +11,7 @@ _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") _CURSOR_BIN = os.environ.get("CURSOR_BIN", "cursor") _SUPPORTED_BACKENDS = {"codex", "cursor"} +_CURSOR_AGENT_BIN = os.path.expanduser("~/.local/bin/cursor-agent") def _resolve_backend(backend): @@ -33,12 +34,15 @@ def _ensure_backend_available(backend, env=None): env_var = "CODEX_BIN" label = "Codex CLI" else: - command = _CURSOR_BIN + command = _cursor_bin(env) env_var = "CURSOR_BIN" label = "Cursor agent CLI" merged = _merged_env(env) path_value = None if merged is None else merged.get("PATH") - resolved = shutil.which(command, path=path_value) + if os.path.isabs(command): + resolved = command if os.path.exists(command) else None + else: + resolved = shutil.which(command, path=path_value) if resolved: return resolved raise RuntimeError( @@ -55,6 +59,8 @@ def agent( backend=None, env=None, fast=False, + model=None, + thinking=None, ): """Run a single agent turn and return only the agent's message. @@ -67,12 +73,24 @@ def agent( backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. Returns: The agent's visible response text with reasoning traces removed. """ message, _thread_id, _usage = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend, env, fast + prompt, + cwd, + None, + yolo, + flags, + include_thinking, + backend, + env, + fast, + model, + thinking, ) return message @@ -106,6 +124,8 @@ def __init__( backend=None, env=None, fast=False, + model=None, + thinking=None, ): """Create a new session wrapper. @@ -120,6 +140,8 @@ def __init__( backend: Agent backend to use ("codex" or "cursor"). env: Optional environment variables for the backend subprocess. fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. """ self.cwd = cwd self._yolo = yolo @@ -130,6 +152,8 @@ def __init__( self._backend = backend self._env = env self._fast = fast + self._model = model + self._thinking = thinking self.last_usage = {} def __call__(self, prompt): @@ -146,6 +170,8 @@ def __call__(self, prompt): self._backend, self._env, self._fast, + self._model, + self._thinking, ) if thread_id: self.thread_id = thread_id @@ -165,15 +191,49 @@ def _run_agent( backend, env, fast=False, + model=None, + thinking=None, ): backend = _resolve_backend(backend) _ensure_backend_available(backend, env) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast) - return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env) + return _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast, + model, + thinking, + ) + return _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model, + thinking, + ) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast=False): +def _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast=False, + model=None, + thinking=None, +): """Invoke the Codex CLI and return the message plus thread id (if any).""" command = [ _CODEX_BIN, @@ -188,6 +248,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env, fast= else: command.append("--full-auto") command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -227,11 +288,82 @@ def _codex_fast_config(fast): return ["-c", "features.fast_mode=false"] -def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): +def _cursor_bin(env=None): + merged = _merged_env(env) + env_source = merged or os.environ + override = env_source.get("CURSOR_BIN", "").strip() + if override: + return os.path.expanduser(override) + + path_value = None if merged is None else merged.get("PATH") + direct = shutil.which("cursor-agent", path=path_value) + if direct: + return direct + if os.path.exists(_CURSOR_AGENT_BIN): + return _CURSOR_AGENT_BIN + return _CURSOR_BIN + + +def _cursor_command_prefix(env=None): + command = _cursor_bin(env) + if os.path.basename(command) == "cursor-agent": + return [command] + return [command, "agent"] + + +def build_agent_flags(*, backend=None, model=None, thinking=None, flags=None): + """Return raw backend flags for a model/thinking configuration. + + The returned string is suitable for APIs that accept the existing ``flags`` + parameter. + """ + backend = _resolve_backend(backend) + parts = _agent_config_flag_parts(backend, model, thinking) + if flags: + parts.extend(shlex.split(flags)) + return shlex.join(parts) + + +def _agent_config_flag_parts(backend, model=None, thinking=None): + backend = _resolve_backend(backend) + parts = [] + model = _clean_optional_text(model) + thinking = _clean_optional_text(thinking) + + if backend == "codex": + if model: + parts.extend(["-c", f"model={model}"]) + if thinking: + parts.extend(["-c", f"model_reasoning_effort={thinking}"]) + return parts + + if model: + parts.extend(["--model", model]) + if thinking: + raise ValueError("thinking is only supported by the codex backend") + return parts + + +def _clean_optional_text(value): + if value is None: + return None + text = str(value).strip() + return text or None + + +def _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model=None, + thinking=None, +): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" - command = [ - _CURSOR_BIN, - "agent", + command = _cursor_command_prefix(env) + [ "--trust", ] if cwd: @@ -240,6 +372,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env): command.extend(["--resume", thread_id]) if yolo: command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) if flags: command.extend(shlex.split(flags)) command.extend(["--print", "--output-format", "json"]) diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index e203057..ac0eca1 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -12,8 +12,9 @@ from .agent import ( _CODEX_BIN, - _CURSOR_BIN, + _agent_config_flag_parts, _codex_fast_config, + _cursor_command_prefix, _ensure_backend_available, _event_usage, _merged_env, @@ -88,6 +89,8 @@ def start( env=None, name=None, fast=False, + model=None, + thinking=None, ): """Start a backend subprocess and return an async handle immediately.""" if not isinstance(prompt, str) or not prompt.strip(): @@ -95,7 +98,7 @@ def start( backend = _resolve_backend(backend) _ensure_backend_available(backend, env) - command = _build_command(backend, cwd, yolo, flags, fast) + command = _build_command(backend, cwd, yolo, flags, fast, model, thinking) process = subprocess.Popen( command, stdin=subprocess.PIPE, @@ -376,13 +379,13 @@ def _current_final_output_locked(self) -> str: return self._rollout_final_output -def _build_command(backend, cwd, yolo, flags, fast=False): +def _build_command(backend, cwd, yolo, flags, fast=False, model=None, thinking=None): if backend == "codex": - return _build_codex_command(cwd, yolo, flags, fast) - return _build_cursor_command(cwd, yolo, flags) + return _build_codex_command(cwd, yolo, flags, fast, model, thinking) + return _build_cursor_command(cwd, yolo, flags, model, thinking) -def _build_codex_command(cwd, yolo, flags, fast=False): +def _build_codex_command(cwd, yolo, flags, fast=False, model=None, thinking=None): command = [ _CODEX_BIN, "exec", @@ -396,6 +399,7 @@ def _build_codex_command(cwd, yolo, flags, fast=False): else: command.append("--full-auto") command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -404,16 +408,15 @@ def _build_codex_command(cwd, yolo, flags, fast=False): return command -def _build_cursor_command(cwd, yolo, flags): - command = [ - _CURSOR_BIN, - "agent", +def _build_cursor_command(cwd, yolo, flags, model=None, thinking=None): + command = _cursor_command_prefix() + [ "--trust", ] if cwd: command.extend(["--workspace", os.fspath(cwd)]) if yolo: command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) if flags: command.extend(shlex.split(flags)) command.extend(["--print", "--output-format", "json"]) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index d54a5fb..95d8031 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -2,11 +2,12 @@ import sys import unittest from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from codexapi.agent import _codex_fast_config, _parse_jsonl -from codexapi.async_agent import _build_codex_command +from codexapi.agent import _codex_fast_config, _parse_jsonl, build_agent_flags +from codexapi.async_agent import _build_codex_command, _build_cursor_command class AgentBackendTests(unittest.TestCase): @@ -34,6 +35,35 @@ def test_async_codex_command_can_enable_fast_mode(self): self.assertIn("service_tier=fast", command) self.assertIn("features.fast_mode=true", command) + def test_build_agent_flags_maps_codex_model_and_thinking_to_config(self): + self.assertEqual( + build_agent_flags(backend="codex", model="gpt-5.5", thinking="xhigh"), + "-c model=gpt-5.5 -c model_reasoning_effort=xhigh", + ) + + def test_build_agent_flags_maps_cursor_model_to_model_flag(self): + self.assertEqual( + build_agent_flags(backend="cursor", model="claude-4"), + "--model claude-4", + ) + + def test_build_agent_flags_rejects_cursor_thinking(self): + with self.assertRaises(ValueError): + build_agent_flags(backend="cursor", thinking="high") + + def test_async_codex_command_can_set_model_and_thinking(self): + command = _build_codex_command(None, True, None, model="gpt-5.5", thinking="high") + self.assertIn("model=gpt-5.5", command) + self.assertIn("model_reasoning_effort=high", command) + + def test_async_cursor_command_can_use_direct_cursor_agent(self): + with patch("codexapi.async_agent._cursor_command_prefix", return_value=["/tmp/cursor-agent"]): + command = _build_cursor_command("/tmp/work", True, None, model="composer-2") + self.assertEqual(command[0], "/tmp/cursor-agent") + self.assertNotEqual(command[1], "agent") + self.assertIn("--model", command) + self.assertIn("composer-2", command) + def test_parse_jsonl_extracts_last_token_usage(self): output = "\n".join( [ From 922dcd25adb1816910af619eabd805902f88ac43 Mon Sep 17 00:00:00 2001 From: Mark O'Connor Date: Tue, 23 Jun 2026 13:03:51 +0200 Subject: [PATCH 31/31] Release v0.12.11 --- README.md | 21 +++++++++++++++------ pyproject.toml | 2 +- src/codexapi/__init__.py | 2 +- src/codexapi/agent.py | 18 ++++++++++-------- src/codexapi/async_agent.py | 8 ++------ src/codexapi/cli.py | 17 +++++++++-------- src/codexapi/lead.py | 2 +- src/codexapi/task.py | 2 +- tests/test_agent_backend.py | 19 +++++++++++++++++++ 9 files changed, 59 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3408e33..b23726e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ Resume a session and print the thread/session id to stderr: codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` -Use `--no-yolo` to disable `--yolo` (Codex uses `--full-auto`). +Use `--no-yolo` to keep unattended operation with Codex auto approvals, without +forcing a sandbox policy. Use `--include-thinking` to return all agent messages joined together for `codexapi run` (Codex only). Lead mode periodically checks in on a long-running agent session with the @@ -290,7 +291,8 @@ codexapi ralph --cancel --cwd /path/to/project ``` Science mode wraps a short task in a science prompt and runs it through the -Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. +Ralph loop. It defaults to dangerous no-sandbox automation and expects progress +notes in `SCIENCE.md`. Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts any improved figures of merit for optional notifications. You can also set `--max-duration` to stop after the current iteration once a time limit is hit. @@ -330,7 +332,9 @@ items are filtered out. - `prompt` (str): prompt to send to the agent backend. - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). @@ -345,7 +349,9 @@ the same conversation and returns only the agent's message. - `__call__(prompt) -> str`: send a prompt to the agent backend and return the message. - `thread_id -> str | None`: expose the underlying session id once created. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. @@ -428,7 +434,9 @@ Runs a task file over a list of items, updating the list file in place. - `task_file` (str | PathLike): YAML task file (must include `prompt`). - `n` (int | None): limit parallelism to N (default: run all items in parallel). - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). - `fast` (bool): enable Codex fast mode (defaults to normal mode). @@ -450,7 +458,8 @@ Simple result object returned by `foreach()`. `fast=True` / `--fast` also passes `service_tier=fast` and `features.fast_mode=true`. - Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. - `include_thinking=True` only affects Codex; Cursor returns a single result string. -- Passes `--yolo` by default (Codex uses `--full-auto` when disabled). +- Uses dangerous no-sandbox automation by default. For Codex, `yolo=False` + uses auto approvals without forcing a sandbox policy. - Raises `RuntimeError` if the backend exits non-zero or returns no agent message. ## Configuration diff --git a/pyproject.toml b/pyproject.toml index c290aa6..f93a31b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.12.10" +version = "0.12.11" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 5fc8665..bd80db3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -30,4 +30,4 @@ "task_result", "lead", ] -__version__ = "0.12.10" +__version__ = "0.12.11" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index d2e9fb8..8adad03 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -67,7 +67,7 @@ def agent( Args: prompt: The user prompt to send to the agent backend. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). @@ -131,7 +131,7 @@ def __init__( Args: cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. thread_id: Optional thread/session id to resume from the first call. flags: Additional raw CLI flags to pass to the agent backend. welfare: When true, append welfare stop instructions to each prompt @@ -235,18 +235,13 @@ def _run_codex( thinking=None, ): """Invoke the Codex CLI and return the message plus thread id (if any).""" - command = [ - _CODEX_BIN, + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ "exec", "--json", "--color", "never", "--skip-git-repo-check", ] - if yolo: - command.append("--yolo") - else: - command.append("--full-auto") command.extend(_codex_fast_config(fast)) command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: @@ -276,6 +271,13 @@ def _run_codex( return _parse_jsonl(result.stdout, include_thinking) +def _codex_automation_flags(yolo): + """Return current Codex CLI flags for unattended operation.""" + if yolo: + return ["--dangerously-bypass-approvals-and-sandbox"] + return ["--ask-for-approval", "never"] + + def _codex_fast_config(fast): """Return Codex config flags for normal or fast mode.""" if fast: diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py index ac0eca1..edc2cf9 100644 --- a/src/codexapi/async_agent.py +++ b/src/codexapi/async_agent.py @@ -13,6 +13,7 @@ from .agent import ( _CODEX_BIN, _agent_config_flag_parts, + _codex_automation_flags, _codex_fast_config, _cursor_command_prefix, _ensure_backend_available, @@ -386,18 +387,13 @@ def _build_command(backend, cwd, yolo, flags, fast=False, model=None, thinking=N def _build_codex_command(cwd, yolo, flags, fast=False, model=None, thinking=None): - command = [ - _CODEX_BIN, + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ "exec", "--json", "--color", "never", "--skip-git-repo-check", ] - if yolo: - command.append("--yolo") - else: - command.append("--full-auto") command.extend(_codex_fast_config(fast)) command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index 830f0ab..3e3e790 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -1439,7 +1439,8 @@ def main(argv=None): science_help = ( "Science mode (science command):\n" " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" - " Default uses --yolo. Use --no-yolo to disable it.\n" + " Default uses dangerous no-sandbox automation. " + "Use --no-yolo for auto approvals without forcing sandbox policy.\n" " Optional --max-duration stops before starting the next iteration once\n" " the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n" ) @@ -1479,7 +1480,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) run_parser.add_argument( "--flags", @@ -1535,7 +1536,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) lead_parser.add_argument( "--flags", @@ -1604,7 +1605,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) agent_start.add_argument( "--flags", @@ -1837,7 +1838,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) task_parser.add_argument( "--flags", @@ -1911,7 +1912,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) ralph_parser.add_argument( "--flags", @@ -1982,7 +1983,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) science_parser.add_argument( "--flags", @@ -2033,7 +2034,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) foreach_parser.add_argument( "--flags", diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index a7c28b5..54a41cb 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -91,7 +91,7 @@ def lead( minutes: Check-in interval in whole minutes (>= 0). prompt: The original instruction prompt. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. backend: Agent backend to use ("codex" or "cursor"). diff --git a/src/codexapi/task.py b/src/codexapi/task.py index 436425a..7a312bf 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -275,7 +275,7 @@ def task( a string check prompt. The string "None" skips verification. max_iterations: Maximum number of task iterations (0 means unlimited). cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. progress: Whether to show a tqdm progress bar with status updates. set_up: Optional setup prompt to run before the task. diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 95d8031..10cd170 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -30,6 +30,25 @@ def test_async_codex_command_uses_normal_mode_by_default(self): self.assertIn("features.fast_mode=false", command) self.assertNotIn("service_tier=fast", command) + def test_async_codex_command_uses_documented_yolo_flags(self): + command = _build_codex_command(None, True, None) + exec_index = command.index("exec") + self.assertIn("--dangerously-bypass-approvals-and-sandbox", command) + self.assertLess( + command.index("--dangerously-bypass-approvals-and-sandbox"), + exec_index, + ) + self.assertGreater(command.index("--json"), exec_index) + self.assertNotIn("--yolo", command) + + def test_async_codex_command_no_yolo_uses_auto_approval_mode(self): + command = _build_codex_command(None, False, None) + exec_index = command.index("exec") + self.assertLess(command.index("--ask-for-approval"), exec_index) + self.assertIn("never", command) + self.assertNotIn("--sandbox", command) + self.assertNotIn("--full-auto", command) + def test_async_codex_command_can_enable_fast_mode(self): command = _build_codex_command(None, True, None, fast=True) self.assertIn("service_tier=fast", command)