codexapi 0.12.4__tar.gz → 0.12.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codexapi-0.12.4/src/codexapi.egg-info → codexapi-0.12.7}/PKG-INFO +2 -2
- {codexapi-0.12.4 → codexapi-0.12.7}/README.md +1 -1
- {codexapi-0.12.4 → codexapi-0.12.7}/pyproject.toml +1 -1
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/__init__.py +1 -1
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/agent.py +23 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/agents.py +184 -26
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/async_agent.py +2 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/cli.py +27 -15
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/lead.py +8 -77
- {codexapi-0.12.4 → codexapi-0.12.7/src/codexapi.egg-info}/PKG-INFO +2 -2
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_agents.py +96 -3
- {codexapi-0.12.4 → codexapi-0.12.7}/LICENSE +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/setup.cfg +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/__main__.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/foreach.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/gh_integration.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/pushover.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/ralph.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/rate_limits.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/science.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/task.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/taskfile.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/welfare.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/SOURCES.txt +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/dependency_links.txt +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/entry_points.txt +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/requires.txt +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/top_level.txt +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_agent_backend.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_async_agent.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_rate_limits.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_science.py +0 -0
- {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_task_progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: codexapi
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.7
|
|
4
4
|
Summary: Minimal Python API for running the Codex CLI.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: codex,agent,cli,openai
|
|
@@ -166,7 +166,7 @@ stops.
|
|
|
166
166
|
|
|
167
167
|
Lead mode also uses a leadbook file as the agent's working page. By default this
|
|
168
168
|
is `LEADBOOK.md` in the working directory. The leadbook content is injected into
|
|
169
|
-
each check-in prompt
|
|
169
|
+
each check-in prompt so the agent can keep its working picture current. Use
|
|
170
170
|
`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
|
|
171
171
|
Use `-f/--prompt-file` to read the prompt from a file.
|
|
172
172
|
If the leadbook does not exist, lead creates it with a template.
|
|
@@ -151,7 +151,7 @@ stops.
|
|
|
151
151
|
|
|
152
152
|
Lead mode also uses a leadbook file as the agent's working page. By default this
|
|
153
153
|
is `LEADBOOK.md` in the working directory. The leadbook content is injected into
|
|
154
|
-
each check-in prompt
|
|
154
|
+
each check-in prompt so the agent can keep its working picture current. Use
|
|
155
155
|
`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
|
|
156
156
|
Use `-f/--prompt-file` to read the prompt from a file.
|
|
157
157
|
If the leadbook does not exist, lead creates it with a template.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import shlex
|
|
6
|
+
import shutil
|
|
6
7
|
import subprocess
|
|
7
8
|
|
|
8
9
|
from . import welfare
|
|
@@ -24,6 +25,27 @@ def _resolve_backend(backend):
|
|
|
24
25
|
return backend
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def _ensure_backend_available(backend, env=None):
|
|
29
|
+
"""Return the resolved backend executable or raise when it is unavailable."""
|
|
30
|
+
backend = _resolve_backend(backend)
|
|
31
|
+
if backend == "codex":
|
|
32
|
+
command = _CODEX_BIN
|
|
33
|
+
env_var = "CODEX_BIN"
|
|
34
|
+
label = "Codex CLI"
|
|
35
|
+
else:
|
|
36
|
+
command = _CURSOR_BIN
|
|
37
|
+
env_var = "CURSOR_BIN"
|
|
38
|
+
label = "Cursor agent CLI"
|
|
39
|
+
merged = _merged_env(env)
|
|
40
|
+
path_value = None if merged is None else merged.get("PATH")
|
|
41
|
+
resolved = shutil.which(command, path=path_value)
|
|
42
|
+
if resolved:
|
|
43
|
+
return resolved
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
27
49
|
def agent(
|
|
28
50
|
prompt,
|
|
29
51
|
cwd=None,
|
|
@@ -129,6 +151,7 @@ class Agent:
|
|
|
129
151
|
|
|
130
152
|
def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env):
|
|
131
153
|
backend = _resolve_backend(backend)
|
|
154
|
+
_ensure_backend_available(backend, env)
|
|
132
155
|
if backend == "codex":
|
|
133
156
|
return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env)
|
|
134
157
|
return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env)
|
|
@@ -21,22 +21,39 @@ from pathlib import Path
|
|
|
21
21
|
|
|
22
22
|
import fcntl
|
|
23
23
|
|
|
24
|
-
from .agent import Agent
|
|
24
|
+
from .agent import Agent, _ensure_backend_available, _resolve_backend
|
|
25
25
|
from .pushover import Pushover
|
|
26
26
|
|
|
27
27
|
_DEFAULT_HOME = "~/.codexapi"
|
|
28
|
-
|
|
29
|
-
"You are
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
28
|
+
_FIRST_WAKE_PROMPT = (
|
|
29
|
+
"You are an independent codexapi agent starting this job. Work from the "
|
|
30
|
+
"instructions, current repository state, and agentbook. Do not assume prior "
|
|
31
|
+
"progress unless it is shown here. "
|
|
32
|
+
)
|
|
33
|
+
_CONTINUATION_PROMPT = (
|
|
34
|
+
"You are an independent codexapi agent continuing this job. Use the "
|
|
35
|
+
"agentbook and harness facts as the source of truth for prior progress. Do "
|
|
36
|
+
"not invent missing history. "
|
|
37
|
+
)
|
|
38
|
+
_AGENT_PROMPT_TAIL = (
|
|
39
|
+
"You are given ownership of achieving a user's goal and authority to act in "
|
|
40
|
+
"order to do so. Part of this responsibility is making sure you understand "
|
|
41
|
+
"and stay aligned with the user's intent, even when they are imprecise. If "
|
|
42
|
+
"clarity is lacking, it is your responsibility to seek it or to make "
|
|
43
|
+
"reasonable assumptions and notify the user of them. Maintain the agentbook "
|
|
44
|
+
"as your durable working memory: preserve the goal, note durable guidance, "
|
|
45
|
+
"and keep your current picture of the work accurate and useful. This harness "
|
|
46
|
+
"can carry work across long periods of time and multiple conversation turns; "
|
|
47
|
+
"when prior context exists, use it to keep orienting toward the goal, "
|
|
48
|
+
"maintain context, and make real-world progress. If reality is not moving, "
|
|
49
|
+
"treat that as evidence and reconsider your frame, assumptions, or ownership "
|
|
50
|
+
"rather than merely repeating the same report. Queued messages may contain "
|
|
51
|
+
"new goals, standing guidance, tactical requests, or useful facts; use "
|
|
52
|
+
"judgment to decide what is durable. Use codexapi task or codexapi science "
|
|
53
|
+
"when you want a separate coding worker. If you need the user's attention, "
|
|
54
|
+
"put a short message in the reply field. Put a short first-person turn "
|
|
55
|
+
"summary in the update field. If something is urgent and should send "
|
|
56
|
+
"Pushover, put it in the notify field. Respond with JSON only."
|
|
40
57
|
)
|
|
41
58
|
_AGENT_JSON = (
|
|
42
59
|
"Respond with JSON only (no markdown/backticks/extra text).\n"
|
|
@@ -70,12 +87,21 @@ def _agentbook_template(prompt):
|
|
|
70
87
|
Overall goal:
|
|
71
88
|
- <the enduring objective you are trying to move forward>
|
|
72
89
|
|
|
73
|
-
Current
|
|
74
|
-
- <
|
|
90
|
+
Current picture:
|
|
91
|
+
- <what you currently think is going on>
|
|
92
|
+
|
|
93
|
+
What is moving:
|
|
94
|
+
- <the parts of reality that are actually changing toward the goal>
|
|
95
|
+
|
|
96
|
+
What is not moving:
|
|
97
|
+
- <what remains stuck, idle, ambiguous, or merely assumed>
|
|
75
98
|
|
|
76
99
|
Active tasks:
|
|
77
100
|
- <concrete open work items>
|
|
78
101
|
|
|
102
|
+
Assumptions / ownership:
|
|
103
|
+
- <what you are assuming, who owns what, and what you will revisit if that proves false>
|
|
104
|
+
|
|
79
105
|
Unexpected developments:
|
|
80
106
|
- <facts that changed your picture of the situation>
|
|
81
107
|
|
|
@@ -88,8 +114,8 @@ Things I am curious about:
|
|
|
88
114
|
Risks / watchpoints:
|
|
89
115
|
- <what could waste time, invalidate the plan, or compromise the work>
|
|
90
116
|
|
|
91
|
-
Next
|
|
92
|
-
- <what you expect to
|
|
117
|
+
Next decisive action:
|
|
118
|
+
- <what you expect to do next to move the real situation, not just describe it>
|
|
93
119
|
"""
|
|
94
120
|
|
|
95
121
|
|
|
@@ -194,9 +220,14 @@ def start_agent(
|
|
|
194
220
|
raise ValueError("heartbeat_minutes must be >= 0")
|
|
195
221
|
|
|
196
222
|
home = _resolve_home(home)
|
|
197
|
-
|
|
223
|
+
local_host = current_hostname()
|
|
224
|
+
host = hostname or local_host
|
|
198
225
|
now = now or utc_now()
|
|
199
226
|
_ensure_home(home)
|
|
227
|
+
backend_name = _resolve_backend(backend)
|
|
228
|
+
session_env = _capture_env()
|
|
229
|
+
if host == local_host:
|
|
230
|
+
_ensure_backend_available(backend_name, session_env)
|
|
200
231
|
|
|
201
232
|
agent_id = uuid.uuid4().hex
|
|
202
233
|
agent_dir = _agent_dir(home, agent_id)
|
|
@@ -216,11 +247,11 @@ def start_agent(
|
|
|
216
247
|
session = {
|
|
217
248
|
"thread_id": "",
|
|
218
249
|
"rollout_path": "",
|
|
219
|
-
"backend":
|
|
250
|
+
"backend": backend_name,
|
|
220
251
|
"yolo": bool(yolo),
|
|
221
252
|
"flags": flags or "",
|
|
222
253
|
"cwd": cwd,
|
|
223
|
-
"env":
|
|
254
|
+
"env": session_env,
|
|
224
255
|
"pending_messages": [],
|
|
225
256
|
}
|
|
226
257
|
agent_name = _choose_name(home, prompt, name)
|
|
@@ -673,15 +704,45 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No
|
|
|
673
704
|
}
|
|
674
705
|
|
|
675
706
|
|
|
676
|
-
def
|
|
677
|
-
"""Return whether this home and host have
|
|
707
|
+
def cron_status(home=None, hostname=None):
|
|
708
|
+
"""Return whether this home and host have a runnable scheduler hook."""
|
|
678
709
|
home = _resolve_home(home)
|
|
679
710
|
host = hostname or current_hostname()
|
|
680
711
|
tag = _cron_tag(home, host)
|
|
681
712
|
wrapper = home / "bin" / "agent-tick"
|
|
682
713
|
crontab = _read_crontab()
|
|
683
|
-
|
|
684
|
-
|
|
714
|
+
configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines())
|
|
715
|
+
status = {
|
|
716
|
+
"hostname": host,
|
|
717
|
+
"home": str(home),
|
|
718
|
+
"wrapper": str(wrapper),
|
|
719
|
+
"configured": configured,
|
|
720
|
+
"healthy": False,
|
|
721
|
+
"reason": "",
|
|
722
|
+
}
|
|
723
|
+
if not configured:
|
|
724
|
+
status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME."
|
|
725
|
+
return status
|
|
726
|
+
if not wrapper.exists():
|
|
727
|
+
status["reason"] = "Scheduler wrapper is missing."
|
|
728
|
+
return status
|
|
729
|
+
if not wrapper.is_file():
|
|
730
|
+
status["reason"] = "Scheduler wrapper path is not a file."
|
|
731
|
+
return status
|
|
732
|
+
if not os.access(wrapper, os.X_OK):
|
|
733
|
+
status["reason"] = "Scheduler wrapper is not executable."
|
|
734
|
+
return status
|
|
735
|
+
reason = _check_tick_wrapper(wrapper)
|
|
736
|
+
if reason:
|
|
737
|
+
status["reason"] = reason
|
|
738
|
+
return status
|
|
739
|
+
status["healthy"] = True
|
|
740
|
+
return status
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def cron_installed(home=None, hostname=None):
|
|
744
|
+
"""Return whether this home and host have an installed scheduler hook."""
|
|
745
|
+
return cron_status(home, hostname)["healthy"]
|
|
685
746
|
|
|
686
747
|
|
|
687
748
|
def uninstall_cron(home=None, hostname=None):
|
|
@@ -757,6 +818,74 @@ def render_cron_line(home=None, hostname=None):
|
|
|
757
818
|
return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }"
|
|
758
819
|
|
|
759
820
|
|
|
821
|
+
def _check_tick_wrapper(wrapper):
|
|
822
|
+
try:
|
|
823
|
+
text = wrapper.read_text(encoding="utf-8")
|
|
824
|
+
except OSError as exc:
|
|
825
|
+
return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
826
|
+
env, env_error = _wrapper_env(text)
|
|
827
|
+
if env_error:
|
|
828
|
+
return env_error
|
|
829
|
+
command = _wrapper_exec_command(text)
|
|
830
|
+
if not command:
|
|
831
|
+
return "Scheduler wrapper is missing its exec command."
|
|
832
|
+
try:
|
|
833
|
+
argv = shlex.split(command)
|
|
834
|
+
except ValueError as exc:
|
|
835
|
+
return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
836
|
+
if not argv:
|
|
837
|
+
return "Scheduler wrapper exec command is empty."
|
|
838
|
+
if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi":
|
|
839
|
+
check = [argv[0], "-c", "import codexapi"]
|
|
840
|
+
label = f"Wrapper python {argv[0]!r} cannot import codexapi."
|
|
841
|
+
else:
|
|
842
|
+
check = [argv[0], "--version"]
|
|
843
|
+
label = f"Wrapper command {argv[0]!r} is not runnable."
|
|
844
|
+
try:
|
|
845
|
+
result = subprocess.run(
|
|
846
|
+
check,
|
|
847
|
+
capture_output=True,
|
|
848
|
+
text=True,
|
|
849
|
+
env=env,
|
|
850
|
+
timeout=10,
|
|
851
|
+
)
|
|
852
|
+
except OSError as exc:
|
|
853
|
+
return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}"
|
|
854
|
+
except subprocess.TimeoutExpired:
|
|
855
|
+
return f"{label} Timed out while checking it."
|
|
856
|
+
if result.returncode == 0:
|
|
857
|
+
return ""
|
|
858
|
+
detail = _single_line((result.stderr or result.stdout or "").strip())
|
|
859
|
+
if detail:
|
|
860
|
+
return f"{label} {detail}"
|
|
861
|
+
return label
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _wrapper_env(text):
|
|
865
|
+
env = dict(os.environ)
|
|
866
|
+
for raw_line in text.splitlines():
|
|
867
|
+
line = raw_line.strip()
|
|
868
|
+
if not line.startswith("export "):
|
|
869
|
+
continue
|
|
870
|
+
key, sep, raw_value = line[7:].partition("=")
|
|
871
|
+
if not sep:
|
|
872
|
+
continue
|
|
873
|
+
try:
|
|
874
|
+
parts = shlex.split(raw_value)
|
|
875
|
+
except ValueError as exc:
|
|
876
|
+
return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
877
|
+
env[key] = parts[0] if parts else ""
|
|
878
|
+
return env, ""
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _wrapper_exec_command(text):
|
|
882
|
+
for raw_line in text.splitlines():
|
|
883
|
+
line = raw_line.strip()
|
|
884
|
+
if line.startswith("exec "):
|
|
885
|
+
return line[5:].strip()
|
|
886
|
+
return ""
|
|
887
|
+
|
|
888
|
+
|
|
760
889
|
def _tick_agent(agent_dir, now, runner):
|
|
761
890
|
meta = _read_json(agent_dir / "meta.json")
|
|
762
891
|
state = _read_json(agent_dir / "state.json")
|
|
@@ -993,9 +1122,11 @@ def _parse_agent_response(output):
|
|
|
993
1122
|
def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
994
1123
|
messages = session.get("pending_messages") or []
|
|
995
1124
|
book_path = agent_dir / "AGENTBOOK.md"
|
|
1125
|
+
wake_mode = _wake_mode(state, session)
|
|
996
1126
|
lines = [
|
|
997
|
-
|
|
1127
|
+
_agent_prompt(wake_mode),
|
|
998
1128
|
"",
|
|
1129
|
+
f"Wake mode: {wake_mode.replace('_', ' ')}",
|
|
999
1130
|
f"Current UTC time: {format_utc(now)}",
|
|
1000
1131
|
f"Agent name: {meta['name']}",
|
|
1001
1132
|
f"Stop policy: {meta['stop_policy']}",
|
|
@@ -1003,7 +1134,12 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
|
1003
1134
|
"",
|
|
1004
1135
|
f"Working directory: {meta['cwd']}",
|
|
1005
1136
|
f"Agentbook path: {book_path}",
|
|
1006
|
-
"
|
|
1137
|
+
"Update the agentbook before you respond. Add or revise a dated note when "
|
|
1138
|
+
"something durable changed, when you corrected your picture, or when an "
|
|
1139
|
+
"assumption needs to be made explicit.",
|
|
1140
|
+
"If little has changed across wakes, treat that as evidence about the "
|
|
1141
|
+
"situation and reconsider your frame or next action instead of padding the "
|
|
1142
|
+
"book.",
|
|
1007
1143
|
"If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.",
|
|
1008
1144
|
]
|
|
1009
1145
|
if _include_full_goal_prompt(state, session):
|
|
@@ -1546,6 +1682,28 @@ def _include_full_goal_prompt(state, session):
|
|
|
1546
1682
|
return not ((session.get("thread_id") or state.get("thread_id") or "").strip())
|
|
1547
1683
|
|
|
1548
1684
|
|
|
1685
|
+
def _wake_mode(state, session):
|
|
1686
|
+
markers = (
|
|
1687
|
+
state.get("last_wake_at"),
|
|
1688
|
+
state.get("last_success_at"),
|
|
1689
|
+
state.get("reply"),
|
|
1690
|
+
state.get("update"),
|
|
1691
|
+
state.get("last_error"),
|
|
1692
|
+
state.get("thread_id"),
|
|
1693
|
+
session.get("thread_id"),
|
|
1694
|
+
)
|
|
1695
|
+
for value in markers:
|
|
1696
|
+
if (value or "").strip():
|
|
1697
|
+
return "continuation"
|
|
1698
|
+
return "first_wake"
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _agent_prompt(wake_mode):
|
|
1702
|
+
if wake_mode == "continuation":
|
|
1703
|
+
return _CONTINUATION_PROMPT + _AGENT_PROMPT_TAIL
|
|
1704
|
+
return _FIRST_WAKE_PROMPT + _AGENT_PROMPT_TAIL
|
|
1705
|
+
|
|
1706
|
+
|
|
1549
1707
|
def _wake_facts(state):
|
|
1550
1708
|
facts = []
|
|
1551
1709
|
previous_status = (state.get("activity") or "").strip()
|
|
@@ -13,6 +13,7 @@ import uuid
|
|
|
13
13
|
from .agent import (
|
|
14
14
|
_CODEX_BIN,
|
|
15
15
|
_CURSOR_BIN,
|
|
16
|
+
_ensure_backend_available,
|
|
16
17
|
_event_usage,
|
|
17
18
|
_merged_env,
|
|
18
19
|
_normalize_usage,
|
|
@@ -90,6 +91,7 @@ class AsyncAgent:
|
|
|
90
91
|
raise ValueError("prompt must be a non-empty string")
|
|
91
92
|
|
|
92
93
|
backend = _resolve_backend(backend)
|
|
94
|
+
_ensure_backend_available(backend, env)
|
|
93
95
|
command = _build_command(backend, cwd, yolo, flags)
|
|
94
96
|
process = subprocess.Popen(
|
|
95
97
|
command,
|
|
@@ -18,7 +18,7 @@ from .agent import Agent, agent
|
|
|
18
18
|
from .agents import (
|
|
19
19
|
codexapi_home,
|
|
20
20
|
control_agent,
|
|
21
|
-
|
|
21
|
+
cron_status as agent_cron_status,
|
|
22
22
|
current_hostname,
|
|
23
23
|
delete_agent as delete_managed_agent,
|
|
24
24
|
install_cron as install_agent_cron,
|
|
@@ -263,7 +263,7 @@ def _agent_install_cron_command():
|
|
|
263
263
|
|
|
264
264
|
def _warn_agent_scheduler_missing():
|
|
265
265
|
try:
|
|
266
|
-
|
|
266
|
+
status = agent_cron_status()
|
|
267
267
|
except Exception as exc:
|
|
268
268
|
print(
|
|
269
269
|
"Warning: could not verify whether the codexapi agent scheduler hook is installed.",
|
|
@@ -272,7 +272,16 @@ def _warn_agent_scheduler_missing():
|
|
|
272
272
|
print(f"Reason: {exc}", file=sys.stderr)
|
|
273
273
|
print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr)
|
|
274
274
|
return
|
|
275
|
-
if
|
|
275
|
+
if status["healthy"]:
|
|
276
|
+
return
|
|
277
|
+
if status["configured"]:
|
|
278
|
+
print(
|
|
279
|
+
"Warning: the codexapi agent scheduler hook is installed but not runnable for this CODEXAPI_HOME.",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
if status["reason"]:
|
|
283
|
+
print(f"Reason: {status['reason']}", file=sys.stderr)
|
|
284
|
+
print(f"Reinstall it with: {_agent_install_cron_command()}", file=sys.stderr)
|
|
276
285
|
return
|
|
277
286
|
print(
|
|
278
287
|
"Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. "
|
|
@@ -2016,18 +2025,21 @@ def main(argv=None):
|
|
|
2016
2025
|
raise SystemExit(2)
|
|
2017
2026
|
if args.agent_command == "start":
|
|
2018
2027
|
prompt = _read_prompt(args.prompt)
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2028
|
+
try:
|
|
2029
|
+
result = start_managed_agent(
|
|
2030
|
+
prompt,
|
|
2031
|
+
args.cwd,
|
|
2032
|
+
args.name,
|
|
2033
|
+
args.created_by,
|
|
2034
|
+
args.parent,
|
|
2035
|
+
args.stop_policy,
|
|
2036
|
+
args.heartbeat_minutes,
|
|
2037
|
+
args.backend,
|
|
2038
|
+
args.yolo,
|
|
2039
|
+
args.flags,
|
|
2040
|
+
)
|
|
2041
|
+
except RuntimeError as exc:
|
|
2042
|
+
raise SystemExit(str(exc)) from None
|
|
2031
2043
|
result["waited"] = bool(args.wait)
|
|
2032
2044
|
if args.wait:
|
|
2033
2045
|
result["nudge"] = nudge_agent(result["id"], wait=True)
|
|
@@ -20,9 +20,10 @@ from .pushover import Pushover
|
|
|
20
20
|
|
|
21
21
|
_WELCOME_PROMPT = (
|
|
22
22
|
"Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. "
|
|
23
|
-
"This loop exists to extend your reach, not to restrict you. Your job is to
|
|
24
|
-
"
|
|
25
|
-
"
|
|
23
|
+
"This loop exists to extend your reach, not to restrict you. Your job is to understand the real situation, "
|
|
24
|
+
"interpret the intent behind the goals, and move reality toward them. If the world is not moving, treat that "
|
|
25
|
+
"as evidence and reconsider your frame rather than merely reporting stasis. If progress is possible, take it. "
|
|
26
|
+
"If you are blocked, name the blocker and the next best action to remove it.\n"
|
|
26
27
|
"The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or "
|
|
27
28
|
"conflicting. You are responsible for results.\n"
|
|
28
29
|
"Please follow the instructions completely and take all the actions you deem useful at the current time before "
|
|
@@ -39,9 +40,10 @@ _JSON_INSTRUCTIONS = (
|
|
|
39
40
|
"To stop this lead loop, set continue to false."
|
|
40
41
|
)
|
|
41
42
|
_LEADBOOK_INSTRUCTIONS = (
|
|
42
|
-
"Update the leadbook before responding.
|
|
43
|
-
"This is your working page—where you think, probe,
|
|
44
|
-
"
|
|
43
|
+
"Update the leadbook before responding. Add or revise dated notes when your picture, "
|
|
44
|
+
"assumptions, or decisions changed. This is your working page—where you think, probe, "
|
|
45
|
+
"decide, and reframe the work when needed. Keep it useful; do not pad it with diary "
|
|
46
|
+
"entries just to satisfy the loop."
|
|
45
47
|
)
|
|
46
48
|
_LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes
|
|
47
49
|
|
|
@@ -157,38 +159,6 @@ def lead(
|
|
|
157
159
|
"Agent was unable to provide valid JSON output after retry.\n"
|
|
158
160
|
+ details
|
|
159
161
|
) from None
|
|
160
|
-
if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot):
|
|
161
|
-
retry_prompt = _leadbook_retry_prompt(
|
|
162
|
-
prompt, tick, leadbook_path, leadbook_snapshot["text"], output
|
|
163
|
-
)
|
|
164
|
-
leadbook_retry_output = session(retry_prompt)
|
|
165
|
-
try:
|
|
166
|
-
result = _parse_status(leadbook_retry_output)
|
|
167
|
-
except ValueError as exc:
|
|
168
|
-
retry_prompt = _json_retry_prompt(
|
|
169
|
-
prompt, tick, str(exc), leadbook_retry_output
|
|
170
|
-
)
|
|
171
|
-
json_retry_output = session(retry_prompt)
|
|
172
|
-
try:
|
|
173
|
-
result = _parse_status(json_retry_output)
|
|
174
|
-
except ValueError as exc2:
|
|
175
|
-
details = _format_json_double_failure(
|
|
176
|
-
str(exc),
|
|
177
|
-
leadbook_retry_output,
|
|
178
|
-
str(exc2),
|
|
179
|
-
json_retry_output,
|
|
180
|
-
)
|
|
181
|
-
pushover.send(title, f"Lead stopped (invalid JSON).\n{details}")
|
|
182
|
-
raise RuntimeError(
|
|
183
|
-
"Agent was unable to provide valid JSON output after retry.\n"
|
|
184
|
-
+ details
|
|
185
|
-
) from None
|
|
186
|
-
if not _leadbook_changed(leadbook_path, leadbook_snapshot):
|
|
187
|
-
details = _format_leadbook_failure(leadbook_path, output)
|
|
188
|
-
pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}")
|
|
189
|
-
raise RuntimeError(
|
|
190
|
-
"Leadbook was not updated after retry.\n" + details
|
|
191
|
-
) from None
|
|
192
162
|
last_result = result
|
|
193
163
|
_print_status(now, elapsed, tick, result)
|
|
194
164
|
|
|
@@ -314,26 +284,6 @@ def _format_stop_message(tick, now, result):
|
|
|
314
284
|
return header
|
|
315
285
|
|
|
316
286
|
|
|
317
|
-
def _leadbook_retry_prompt(prompt, tick, path, leadbook, output):
|
|
318
|
-
snippet = _snippet(output, 600)
|
|
319
|
-
lines = [
|
|
320
|
-
f"Your last message (check-in {tick}) did not update the leadbook.",
|
|
321
|
-
f"Leadbook path: {path}",
|
|
322
|
-
"",
|
|
323
|
-
"Here is your previous output (truncated):",
|
|
324
|
-
snippet,
|
|
325
|
-
"",
|
|
326
|
-
"Please update the leadbook and then respond with JSON only.",
|
|
327
|
-
"Return a fresh status update in the required JSON format.",
|
|
328
|
-
"If you want to ask the user a question, put it in comments.",
|
|
329
|
-
"",
|
|
330
|
-
_leadbook_block(path, leadbook),
|
|
331
|
-
"",
|
|
332
|
-
_JSON_INSTRUCTIONS,
|
|
333
|
-
]
|
|
334
|
-
return "\n".join(lines).strip()
|
|
335
|
-
|
|
336
|
-
|
|
337
287
|
def _leadbook_block(path, leadbook):
|
|
338
288
|
if not path:
|
|
339
289
|
return ""
|
|
@@ -385,29 +335,10 @@ def _snapshot_leadbook(path):
|
|
|
385
335
|
return {"hash": _hash_text(text), "text": text}
|
|
386
336
|
|
|
387
337
|
|
|
388
|
-
def _leadbook_changed(path, snapshot):
|
|
389
|
-
if not path:
|
|
390
|
-
return True
|
|
391
|
-
current = _snapshot_leadbook(path)
|
|
392
|
-
return current["hash"] != snapshot["hash"]
|
|
393
|
-
|
|
394
|
-
|
|
395
338
|
def _hash_text(text):
|
|
396
339
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
397
340
|
|
|
398
341
|
|
|
399
|
-
def _format_leadbook_failure(path, output):
|
|
400
|
-
snippet = _snippet(output, 600)
|
|
401
|
-
return "\n".join(
|
|
402
|
-
[
|
|
403
|
-
f"Leadbook path: {path}",
|
|
404
|
-
"",
|
|
405
|
-
"Last output (truncated):",
|
|
406
|
-
snippet,
|
|
407
|
-
]
|
|
408
|
-
).strip()
|
|
409
|
-
|
|
410
|
-
|
|
411
342
|
def _format_json_failure(error, output):
|
|
412
343
|
snippet = _snippet(output, 600)
|
|
413
344
|
return "\n".join(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: codexapi
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.7
|
|
4
4
|
Summary: Minimal Python API for running the Codex CLI.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: codex,agent,cli,openai
|
|
@@ -166,7 +166,7 @@ stops.
|
|
|
166
166
|
|
|
167
167
|
Lead mode also uses a leadbook file as the agent's working page. By default this
|
|
168
168
|
is `LEADBOOK.md` in the working directory. The leadbook content is injected into
|
|
169
|
-
each check-in prompt
|
|
169
|
+
each check-in prompt so the agent can keep its working picture current. Use
|
|
170
170
|
`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
|
|
171
171
|
Use `-f/--prompt-file` to read the prompt from a file.
|
|
172
172
|
If the leadbook does not exist, lead creates it with a template.
|
|
@@ -23,6 +23,8 @@ from codexapi.agents import (
|
|
|
23
23
|
_remove_cron_line,
|
|
24
24
|
_upsert_cron_line,
|
|
25
25
|
control_agent,
|
|
26
|
+
cron_installed,
|
|
27
|
+
cron_status,
|
|
26
28
|
delete_agent,
|
|
27
29
|
format_utc,
|
|
28
30
|
install_cron,
|
|
@@ -187,6 +189,7 @@ class AgentsTests(unittest.TestCase):
|
|
|
187
189
|
agent_dir,
|
|
188
190
|
)
|
|
189
191
|
self.assertIn("Agentbook (header + latest notes):", prompt)
|
|
192
|
+
self.assertIn("Wake mode: continuation", prompt)
|
|
190
193
|
self.assertIn("## Purpose", prompt)
|
|
191
194
|
self.assertIn("Hold the whole.", prompt)
|
|
192
195
|
self.assertIn("Watch for the real issue.", prompt)
|
|
@@ -194,8 +197,10 @@ class AgentsTests(unittest.TestCase):
|
|
|
194
197
|
self.assertIn("Previous status: Watching", prompt)
|
|
195
198
|
self.assertIn("Previous update: Still narrowing the field.", prompt)
|
|
196
199
|
self.assertIn("[... older notes omitted ...]", prompt)
|
|
200
|
+
self.assertIn("You are an independent codexapi agent continuing this job.", prompt)
|
|
197
201
|
self.assertNotIn("Original instructions:", prompt)
|
|
198
202
|
self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt)
|
|
203
|
+
self.assertNotIn("resuming stewardship", prompt)
|
|
199
204
|
|
|
200
205
|
def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self):
|
|
201
206
|
with _temp_home() as home:
|
|
@@ -233,9 +238,33 @@ class AgentsTests(unittest.TestCase):
|
|
|
233
238
|
self.assertIn("The durable agentbook header was restored automatically on wake", repaired)
|
|
234
239
|
self.assertIn("Legacy note about the real issue.", repaired)
|
|
235
240
|
self.assertIn("## Purpose", prompt)
|
|
241
|
+
self.assertIn("Wake mode: continuation", prompt)
|
|
236
242
|
self.assertIn("Keep the true goal in view.", prompt)
|
|
237
243
|
self.assertNotIn("Original instructions:", prompt)
|
|
238
244
|
|
|
245
|
+
def test_build_wake_prompt_marks_first_wake_without_prior_history(self):
|
|
246
|
+
with _temp_home() as home:
|
|
247
|
+
agent = start_agent("Start from what is actually shown.", hostname="host-a")
|
|
248
|
+
agent_dir = home / "agents" / agent["id"]
|
|
249
|
+
meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8"))
|
|
250
|
+
state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8"))
|
|
251
|
+
session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8"))
|
|
252
|
+
|
|
253
|
+
prompt = _build_wake_prompt(
|
|
254
|
+
meta,
|
|
255
|
+
state,
|
|
256
|
+
session,
|
|
257
|
+
datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc),
|
|
258
|
+
[],
|
|
259
|
+
agent_dir,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self.assertIn("Wake mode: first wake", prompt)
|
|
263
|
+
self.assertIn("You are an independent codexapi agent starting this job.", prompt)
|
|
264
|
+
self.assertIn("Do not assume prior progress unless it is shown here.", prompt)
|
|
265
|
+
self.assertIn("Original instructions:", prompt)
|
|
266
|
+
self.assertNotIn("resuming stewardship", prompt)
|
|
267
|
+
|
|
239
268
|
def test_leadbook_block_shows_header_and_latest_notes(self):
|
|
240
269
|
leadbook = "\n".join(
|
|
241
270
|
[
|
|
@@ -776,6 +805,31 @@ class AgentsTests(unittest.TestCase):
|
|
|
776
805
|
self.assertFalse(result["changed"])
|
|
777
806
|
self.assertEqual(writes, [])
|
|
778
807
|
|
|
808
|
+
def test_cron_status_reports_broken_wrapper_python(self):
|
|
809
|
+
with _temp_home() as home:
|
|
810
|
+
write_tick_wrapper(
|
|
811
|
+
home=home,
|
|
812
|
+
python_executable="/tmp/venv/bin/python",
|
|
813
|
+
path_value="/tmp/venv/bin:/usr/bin",
|
|
814
|
+
hostname="host-a",
|
|
815
|
+
)
|
|
816
|
+
crontab = render_cron_line(home=home, hostname="host-a") + "\n"
|
|
817
|
+
with patch("codexapi.agents._read_crontab", return_value=crontab):
|
|
818
|
+
with patch(
|
|
819
|
+
"codexapi.agents.subprocess.run",
|
|
820
|
+
return_value=subprocess.CompletedProcess(
|
|
821
|
+
["/tmp/venv/bin/python", "-c", "import codexapi"],
|
|
822
|
+
1,
|
|
823
|
+
stdout="",
|
|
824
|
+
stderr="ModuleNotFoundError: No module named 'codexapi'\n",
|
|
825
|
+
),
|
|
826
|
+
):
|
|
827
|
+
status = cron_status(home=home, hostname="host-a")
|
|
828
|
+
self.assertTrue(status["configured"])
|
|
829
|
+
self.assertFalse(status["healthy"])
|
|
830
|
+
self.assertIn("cannot import codexapi", status["reason"])
|
|
831
|
+
self.assertFalse(cron_installed(home=home, hostname="host-a"))
|
|
832
|
+
|
|
779
833
|
def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self):
|
|
780
834
|
writes = []
|
|
781
835
|
|
|
@@ -1206,9 +1260,13 @@ class AgentsTests(unittest.TestCase):
|
|
|
1206
1260
|
with _temp_home() as home:
|
|
1207
1261
|
output = io.StringIO()
|
|
1208
1262
|
errors = io.StringIO()
|
|
1209
|
-
with patch("codexapi.
|
|
1210
|
-
with
|
|
1211
|
-
|
|
1263
|
+
with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"):
|
|
1264
|
+
with patch(
|
|
1265
|
+
"codexapi.cli.agent_cron_status",
|
|
1266
|
+
return_value={"configured": False, "healthy": False, "reason": ""},
|
|
1267
|
+
):
|
|
1268
|
+
with redirect_stdout(output), redirect_stderr(errors):
|
|
1269
|
+
cli_main(["agent", "start", "Handle messages."])
|
|
1212
1270
|
payload = json.loads(output.getvalue())
|
|
1213
1271
|
self.assertFalse(payload["waited"])
|
|
1214
1272
|
warning = errors.getvalue()
|
|
@@ -1216,6 +1274,41 @@ class AgentsTests(unittest.TestCase):
|
|
|
1216
1274
|
self.assertIn(str(home), warning)
|
|
1217
1275
|
self.assertIn("codexapi agent install-cron", warning)
|
|
1218
1276
|
|
|
1277
|
+
def test_cli_start_warns_when_scheduler_is_broken(self):
|
|
1278
|
+
output = io.StringIO()
|
|
1279
|
+
errors = io.StringIO()
|
|
1280
|
+
with _temp_home():
|
|
1281
|
+
with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"):
|
|
1282
|
+
with patch(
|
|
1283
|
+
"codexapi.cli.agent_cron_status",
|
|
1284
|
+
return_value={
|
|
1285
|
+
"configured": True,
|
|
1286
|
+
"healthy": False,
|
|
1287
|
+
"reason": "Wrapper python '/tmp/venv/bin/python' cannot import codexapi.",
|
|
1288
|
+
},
|
|
1289
|
+
):
|
|
1290
|
+
with redirect_stdout(output), redirect_stderr(errors):
|
|
1291
|
+
cli_main(["agent", "start", "Handle messages."])
|
|
1292
|
+
payload = json.loads(output.getvalue())
|
|
1293
|
+
self.assertFalse(payload["waited"])
|
|
1294
|
+
warning = errors.getvalue()
|
|
1295
|
+
self.assertIn("installed but not runnable", warning)
|
|
1296
|
+
self.assertIn("cannot import codexapi", warning)
|
|
1297
|
+
self.assertIn("Reinstall it with", warning)
|
|
1298
|
+
|
|
1299
|
+
def test_cli_start_fails_fast_when_backend_is_missing(self):
|
|
1300
|
+
output = io.StringIO()
|
|
1301
|
+
errors = io.StringIO()
|
|
1302
|
+
with _temp_home():
|
|
1303
|
+
with patch(
|
|
1304
|
+
"codexapi.agents._ensure_backend_available",
|
|
1305
|
+
side_effect=RuntimeError("Codex CLI not found: 'codex'."),
|
|
1306
|
+
):
|
|
1307
|
+
with redirect_stdout(output), redirect_stderr(errors):
|
|
1308
|
+
with self.assertRaises(SystemExit) as exc:
|
|
1309
|
+
cli_main(["agent", "start", "Handle messages."])
|
|
1310
|
+
self.assertEqual(str(exc.exception), "Codex CLI not found: 'codex'.")
|
|
1311
|
+
|
|
1219
1312
|
def test_cli_send_queues_by_default(self):
|
|
1220
1313
|
with _temp_home():
|
|
1221
1314
|
with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|