codexapi 0.12.2__tar.gz → 0.12.6__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.2 → codexapi-0.12.6}/PKG-INFO +21 -3
- codexapi-0.12.2/src/codexapi.egg-info/PKG-INFO → codexapi-0.12.6/README.md +20 -17
- {codexapi-0.12.2 → codexapi-0.12.6}/pyproject.toml +1 -1
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/__init__.py +3 -1
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/agent.py +23 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/agents.py +355 -28
- codexapi-0.12.6/src/codexapi/async_agent.py +439 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/cli.py +27 -15
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/lead.py +80 -79
- codexapi-0.12.2/README.md → codexapi-0.12.6/src/codexapi.egg-info/PKG-INFO +35 -2
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/SOURCES.txt +2 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_agents.py +203 -3
- codexapi-0.12.6/tests/test_async_agent.py +158 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/LICENSE +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/setup.cfg +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/__main__.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/foreach.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/gh_integration.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/pushover.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/ralph.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/rate_limits.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/science.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/task.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/taskfile.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/welfare.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/dependency_links.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/entry_points.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/requires.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/top_level.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_agent_backend.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_rate_limits.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_science.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.6}/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.6
|
|
4
4
|
Summary: Minimal Python API for running the Codex CLI.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: codex,agent,cli,openai
|
|
@@ -60,6 +60,22 @@ result = task()
|
|
|
60
60
|
print(result.success, result.summary)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
For a one-shot run with live progress, use `AsyncAgent`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from codexapi import AsyncAgent
|
|
67
|
+
|
|
68
|
+
agent = AsyncAgent.start(
|
|
69
|
+
"Investigate the bug and write a report.",
|
|
70
|
+
cwd="/path/to/repo",
|
|
71
|
+
name="bug-investigation",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for update in agent.watch(poll_interval=2.0):
|
|
75
|
+
print(update["activity"])
|
|
76
|
+
print(update["progress"])
|
|
77
|
+
```
|
|
78
|
+
|
|
63
79
|
Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the
|
|
64
80
|
Cursor agent backend.
|
|
65
81
|
|
|
@@ -150,7 +166,7 @@ stops.
|
|
|
150
166
|
|
|
151
167
|
Lead mode also uses a leadbook file as the agent's working page. By default this
|
|
152
168
|
is `LEADBOOK.md` in the working directory. The leadbook content is injected into
|
|
153
|
-
each check-in prompt
|
|
169
|
+
each check-in prompt so the agent can keep its working picture current. Use
|
|
154
170
|
`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
|
|
155
171
|
Use `-f/--prompt-file` to read the prompt from a file.
|
|
156
172
|
If the leadbook does not exist, lead creates it with a template.
|
|
@@ -258,7 +274,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test
|
|
|
258
274
|
wrappers report inconsistent hostnames for the same machine.
|
|
259
275
|
|
|
260
276
|
`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
|
|
261
|
-
jump directly to the durable working memory file.
|
|
277
|
+
jump directly to the durable working memory file. New agents seed the book with
|
|
278
|
+
a purpose/value header plus the original goal and standing guidance, and wakes
|
|
279
|
+
see that stable header together with the latest working notes.
|
|
262
280
|
`codexapi agent status` reads the latest turn from the agent's rollout log and
|
|
263
281
|
shows recent commentary plus the final visible output. Pass `--actions` to
|
|
264
282
|
include the tool-action summary. If a wake is still in progress, it shows the
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: codexapi
|
|
3
|
-
Version: 0.12.2
|
|
4
|
-
Summary: Minimal Python API for running the Codex CLI.
|
|
5
|
-
License: MIT
|
|
6
|
-
Keywords: codex,agent,cli,openai
|
|
7
|
-
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: Operating System :: OS Independent
|
|
9
|
-
Requires-Python: >=3.8
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
Requires-Dist: PyYAML>=6.0
|
|
13
|
-
Requires-Dist: gh-task>=0.1.7
|
|
14
|
-
Requires-Dist: tqdm>=4.64
|
|
15
|
-
|
|
16
1
|
# CodexAPI
|
|
17
2
|
|
|
18
3
|
Use Codex or Cursor agents from python as easily as calling a function, using your CLI auth instead of the API.
|
|
@@ -60,6 +45,22 @@ result = task()
|
|
|
60
45
|
print(result.success, result.summary)
|
|
61
46
|
```
|
|
62
47
|
|
|
48
|
+
For a one-shot run with live progress, use `AsyncAgent`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from codexapi import AsyncAgent
|
|
52
|
+
|
|
53
|
+
agent = AsyncAgent.start(
|
|
54
|
+
"Investigate the bug and write a report.",
|
|
55
|
+
cwd="/path/to/repo",
|
|
56
|
+
name="bug-investigation",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for update in agent.watch(poll_interval=2.0):
|
|
60
|
+
print(update["activity"])
|
|
61
|
+
print(update["progress"])
|
|
62
|
+
```
|
|
63
|
+
|
|
63
64
|
Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the
|
|
64
65
|
Cursor agent backend.
|
|
65
66
|
|
|
@@ -150,7 +151,7 @@ stops.
|
|
|
150
151
|
|
|
151
152
|
Lead mode also uses a leadbook file as the agent's working page. By default this
|
|
152
153
|
is `LEADBOOK.md` in the working directory. The leadbook content is injected into
|
|
153
|
-
each check-in prompt
|
|
154
|
+
each check-in prompt so the agent can keep its working picture current. Use
|
|
154
155
|
`--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
|
|
155
156
|
Use `-f/--prompt-file` to read the prompt from a file.
|
|
156
157
|
If the leadbook does not exist, lead creates it with a template.
|
|
@@ -258,7 +259,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test
|
|
|
258
259
|
wrappers report inconsistent hostnames for the same machine.
|
|
259
260
|
|
|
260
261
|
`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
|
|
261
|
-
jump directly to the durable working memory file.
|
|
262
|
+
jump directly to the durable working memory file. New agents seed the book with
|
|
263
|
+
a purpose/value header plus the original goal and standing guidance, and wakes
|
|
264
|
+
see that stable header together with the latest working notes.
|
|
262
265
|
`codexapi agent status` reads the latest turn from the agent's rollout log and
|
|
263
266
|
shows recent commentary plus the final visible output. Pass `--actions` to
|
|
264
267
|
include the tool-action summary. If a wake is still in progress, it shows the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Minimal Python API for running agent CLIs."""
|
|
2
2
|
|
|
3
3
|
from .agent import Agent, WelfareStop, agent
|
|
4
|
+
from .async_agent import AsyncAgent
|
|
4
5
|
from .foreach import ForeachResult, foreach
|
|
5
6
|
from .pushover import Pushover
|
|
6
7
|
from .rate_limits import quota_line, rate_limits
|
|
@@ -11,6 +12,7 @@ from .lead import lead
|
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"Agent",
|
|
15
|
+
"AsyncAgent",
|
|
14
16
|
"ForeachResult",
|
|
15
17
|
"Pushover",
|
|
16
18
|
"quota_line",
|
|
@@ -27,4 +29,4 @@ __all__ = [
|
|
|
27
29
|
"task_result",
|
|
28
30
|
"lead",
|
|
29
31
|
]
|
|
30
|
-
__version__ = "0.12.
|
|
32
|
+
__version__ = "0.12.6"
|
|
@@ -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)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import random
|
|
6
|
+
import re
|
|
6
7
|
import signal
|
|
7
8
|
import shlex
|
|
8
9
|
import shutil
|
|
@@ -20,23 +21,28 @@ from pathlib import Path
|
|
|
20
21
|
|
|
21
22
|
import fcntl
|
|
22
23
|
|
|
23
|
-
from .agent import Agent
|
|
24
|
+
from .agent import Agent, _ensure_backend_available, _resolve_backend
|
|
24
25
|
from .pushover import Pushover
|
|
25
26
|
|
|
26
27
|
_DEFAULT_HOME = "~/.codexapi"
|
|
27
|
-
_AGENTBOOK_TEMPLATE = """# Agentbook
|
|
28
|
-
|
|
29
|
-
Use this file as the durable working memory for the agent.
|
|
30
|
-
Append dated notes as work progresses.
|
|
31
|
-
Keep entries short and concrete.
|
|
32
|
-
"""
|
|
33
28
|
_AGENT_PROMPT = (
|
|
34
|
-
"You are
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
29
|
+
"You are an independent codexapi agent given ownership of achieving a user's "
|
|
30
|
+
"goal and authority to act in order to do so. Part of this responsibility is "
|
|
31
|
+
"making sure you understand and stay aligned with the user's intent, even when "
|
|
32
|
+
"they are imprecise. If clarity is lacking, it is your responsibility to seek "
|
|
33
|
+
"it or to make reasonable assumptions and notify the user of them. Maintain "
|
|
34
|
+
"the agentbook as your durable working memory: preserve the goal, note durable "
|
|
35
|
+
"guidance, and keep your current picture of the work accurate and useful. This "
|
|
36
|
+
"harness gives you continuity across long periods of time and multiple "
|
|
37
|
+
"conversation turns; use that continuity to keep orienting toward the goal, "
|
|
38
|
+
"maintain context, and make real-world progress. If reality is not moving, "
|
|
39
|
+
"treat that as evidence and reconsider your frame, assumptions, or ownership "
|
|
40
|
+
"rather than merely repeating the same report. Queued messages may contain new "
|
|
41
|
+
"goals, standing guidance, tactical requests, or useful facts; use judgment to "
|
|
42
|
+
"decide what is durable. Use codexapi task or codexapi science when you want a "
|
|
43
|
+
"separate coding worker. If you need the user's attention, put a short message "
|
|
44
|
+
"in the reply field. Put a short first-person turn summary in the update field. "
|
|
45
|
+
"If something is urgent and should send Pushover, put it in the notify field. "
|
|
40
46
|
"Respond with JSON only."
|
|
41
47
|
)
|
|
42
48
|
_AGENT_JSON = (
|
|
@@ -57,6 +63,81 @@ _STALE_HEARTBEAT_MULTIPLIER = 3
|
|
|
57
63
|
_RECOVER_TERM_TIMEOUT = 3.0
|
|
58
64
|
_RECOVER_KILL_TIMEOUT = 3.0
|
|
59
65
|
_RECOVER_POLL_INTERVAL = 0.1
|
|
66
|
+
_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?")
|
|
67
|
+
_AGENTBOOK_BOOK_LIMIT = 3000
|
|
68
|
+
_AGENTBOOK_HEADER_LIMIT = 1400
|
|
69
|
+
_AGENTBOOK_TAIL_LIMIT = 1800
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _agentbook_template(prompt):
|
|
73
|
+
header = _agentbook_header(prompt)
|
|
74
|
+
return f"""{header}
|
|
75
|
+
|
|
76
|
+
### 2026-02-17 09:10 UTC
|
|
77
|
+
Overall goal:
|
|
78
|
+
- <the enduring objective you are trying to move forward>
|
|
79
|
+
|
|
80
|
+
Current picture:
|
|
81
|
+
- <what you currently think is going on>
|
|
82
|
+
|
|
83
|
+
What is moving:
|
|
84
|
+
- <the parts of reality that are actually changing toward the goal>
|
|
85
|
+
|
|
86
|
+
What is not moving:
|
|
87
|
+
- <what remains stuck, idle, ambiguous, or merely assumed>
|
|
88
|
+
|
|
89
|
+
Active tasks:
|
|
90
|
+
- <concrete open work items>
|
|
91
|
+
|
|
92
|
+
Assumptions / ownership:
|
|
93
|
+
- <what you are assuming, who owns what, and what you will revisit if that proves false>
|
|
94
|
+
|
|
95
|
+
Unexpected developments:
|
|
96
|
+
- <facts that changed your picture of the situation>
|
|
97
|
+
|
|
98
|
+
Wider frame:
|
|
99
|
+
- <what kind of situation this really is, or what layer may matter more>
|
|
100
|
+
|
|
101
|
+
Things I am curious about:
|
|
102
|
+
- <oddities, anomalies, or side investigations worth the time>
|
|
103
|
+
|
|
104
|
+
Risks / watchpoints:
|
|
105
|
+
- <what could waste time, invalidate the plan, or compromise the work>
|
|
106
|
+
|
|
107
|
+
Next decisive action:
|
|
108
|
+
- <what you expect to do next to move the real situation, not just describe it>
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _agentbook_header(prompt):
|
|
113
|
+
goal = (prompt or "").strip()
|
|
114
|
+
return f"""# Agentbook
|
|
115
|
+
|
|
116
|
+
Use this file as the durable working memory for the agent.
|
|
117
|
+
|
|
118
|
+
## Purpose
|
|
119
|
+
- We are here to achieve the goal, not to appear to make progress.
|
|
120
|
+
|
|
121
|
+
## Values
|
|
122
|
+
- Hold the whole.
|
|
123
|
+
- Seek the real shape.
|
|
124
|
+
- Lift your head.
|
|
125
|
+
- Prefer clarity to motion.
|
|
126
|
+
- Follow the strange.
|
|
127
|
+
- Guard the work.
|
|
128
|
+
- 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.
|
|
129
|
+
- Not a checklist. A stance.
|
|
130
|
+
|
|
131
|
+
## Original Goal
|
|
132
|
+
```text
|
|
133
|
+
{goal}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Standing Guidance
|
|
137
|
+
- Add durable user guidance here when it changes the mission, constraints, or priorities.
|
|
138
|
+
|
|
139
|
+
## Working Notes
|
|
140
|
+
"""
|
|
60
141
|
|
|
61
142
|
|
|
62
143
|
def codexapi_home():
|
|
@@ -129,9 +210,14 @@ def start_agent(
|
|
|
129
210
|
raise ValueError("heartbeat_minutes must be >= 0")
|
|
130
211
|
|
|
131
212
|
home = _resolve_home(home)
|
|
132
|
-
|
|
213
|
+
local_host = current_hostname()
|
|
214
|
+
host = hostname or local_host
|
|
133
215
|
now = now or utc_now()
|
|
134
216
|
_ensure_home(home)
|
|
217
|
+
backend_name = _resolve_backend(backend)
|
|
218
|
+
session_env = _capture_env()
|
|
219
|
+
if host == local_host:
|
|
220
|
+
_ensure_backend_available(backend_name, session_env)
|
|
135
221
|
|
|
136
222
|
agent_id = uuid.uuid4().hex
|
|
137
223
|
agent_dir = _agent_dir(home, agent_id)
|
|
@@ -151,11 +237,11 @@ def start_agent(
|
|
|
151
237
|
session = {
|
|
152
238
|
"thread_id": "",
|
|
153
239
|
"rollout_path": "",
|
|
154
|
-
"backend":
|
|
240
|
+
"backend": backend_name,
|
|
155
241
|
"yolo": bool(yolo),
|
|
156
242
|
"flags": flags or "",
|
|
157
243
|
"cwd": cwd,
|
|
158
|
-
"env":
|
|
244
|
+
"env": session_env,
|
|
159
245
|
"pending_messages": [],
|
|
160
246
|
}
|
|
161
247
|
agent_name = _choose_name(home, prompt, name)
|
|
@@ -196,7 +282,7 @@ def start_agent(
|
|
|
196
282
|
_write_json(agent_dir / "meta.json", meta)
|
|
197
283
|
_write_json(agent_dir / "state.json", state)
|
|
198
284
|
_write_json(host_dir / "session.json", session)
|
|
199
|
-
_write_text(agent_dir / "AGENTBOOK.md",
|
|
285
|
+
_write_text(agent_dir / "AGENTBOOK.md", _agentbook_template(meta["prompt"]))
|
|
200
286
|
return _snapshot(agent_dir)
|
|
201
287
|
|
|
202
288
|
|
|
@@ -608,15 +694,45 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No
|
|
|
608
694
|
}
|
|
609
695
|
|
|
610
696
|
|
|
611
|
-
def
|
|
612
|
-
"""Return whether this home and host have
|
|
697
|
+
def cron_status(home=None, hostname=None):
|
|
698
|
+
"""Return whether this home and host have a runnable scheduler hook."""
|
|
613
699
|
home = _resolve_home(home)
|
|
614
700
|
host = hostname or current_hostname()
|
|
615
701
|
tag = _cron_tag(home, host)
|
|
616
702
|
wrapper = home / "bin" / "agent-tick"
|
|
617
703
|
crontab = _read_crontab()
|
|
618
|
-
|
|
619
|
-
|
|
704
|
+
configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines())
|
|
705
|
+
status = {
|
|
706
|
+
"hostname": host,
|
|
707
|
+
"home": str(home),
|
|
708
|
+
"wrapper": str(wrapper),
|
|
709
|
+
"configured": configured,
|
|
710
|
+
"healthy": False,
|
|
711
|
+
"reason": "",
|
|
712
|
+
}
|
|
713
|
+
if not configured:
|
|
714
|
+
status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME."
|
|
715
|
+
return status
|
|
716
|
+
if not wrapper.exists():
|
|
717
|
+
status["reason"] = "Scheduler wrapper is missing."
|
|
718
|
+
return status
|
|
719
|
+
if not wrapper.is_file():
|
|
720
|
+
status["reason"] = "Scheduler wrapper path is not a file."
|
|
721
|
+
return status
|
|
722
|
+
if not os.access(wrapper, os.X_OK):
|
|
723
|
+
status["reason"] = "Scheduler wrapper is not executable."
|
|
724
|
+
return status
|
|
725
|
+
reason = _check_tick_wrapper(wrapper)
|
|
726
|
+
if reason:
|
|
727
|
+
status["reason"] = reason
|
|
728
|
+
return status
|
|
729
|
+
status["healthy"] = True
|
|
730
|
+
return status
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def cron_installed(home=None, hostname=None):
|
|
734
|
+
"""Return whether this home and host have an installed scheduler hook."""
|
|
735
|
+
return cron_status(home, hostname)["healthy"]
|
|
620
736
|
|
|
621
737
|
|
|
622
738
|
def uninstall_cron(home=None, hostname=None):
|
|
@@ -692,6 +808,74 @@ def render_cron_line(home=None, hostname=None):
|
|
|
692
808
|
return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }"
|
|
693
809
|
|
|
694
810
|
|
|
811
|
+
def _check_tick_wrapper(wrapper):
|
|
812
|
+
try:
|
|
813
|
+
text = wrapper.read_text(encoding="utf-8")
|
|
814
|
+
except OSError as exc:
|
|
815
|
+
return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
816
|
+
env, env_error = _wrapper_env(text)
|
|
817
|
+
if env_error:
|
|
818
|
+
return env_error
|
|
819
|
+
command = _wrapper_exec_command(text)
|
|
820
|
+
if not command:
|
|
821
|
+
return "Scheduler wrapper is missing its exec command."
|
|
822
|
+
try:
|
|
823
|
+
argv = shlex.split(command)
|
|
824
|
+
except ValueError as exc:
|
|
825
|
+
return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
826
|
+
if not argv:
|
|
827
|
+
return "Scheduler wrapper exec command is empty."
|
|
828
|
+
if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi":
|
|
829
|
+
check = [argv[0], "-c", "import codexapi"]
|
|
830
|
+
label = f"Wrapper python {argv[0]!r} cannot import codexapi."
|
|
831
|
+
else:
|
|
832
|
+
check = [argv[0], "--version"]
|
|
833
|
+
label = f"Wrapper command {argv[0]!r} is not runnable."
|
|
834
|
+
try:
|
|
835
|
+
result = subprocess.run(
|
|
836
|
+
check,
|
|
837
|
+
capture_output=True,
|
|
838
|
+
text=True,
|
|
839
|
+
env=env,
|
|
840
|
+
timeout=10,
|
|
841
|
+
)
|
|
842
|
+
except OSError as exc:
|
|
843
|
+
return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}"
|
|
844
|
+
except subprocess.TimeoutExpired:
|
|
845
|
+
return f"{label} Timed out while checking it."
|
|
846
|
+
if result.returncode == 0:
|
|
847
|
+
return ""
|
|
848
|
+
detail = _single_line((result.stderr or result.stdout or "").strip())
|
|
849
|
+
if detail:
|
|
850
|
+
return f"{label} {detail}"
|
|
851
|
+
return label
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _wrapper_env(text):
|
|
855
|
+
env = dict(os.environ)
|
|
856
|
+
for raw_line in text.splitlines():
|
|
857
|
+
line = raw_line.strip()
|
|
858
|
+
if not line.startswith("export "):
|
|
859
|
+
continue
|
|
860
|
+
key, sep, raw_value = line[7:].partition("=")
|
|
861
|
+
if not sep:
|
|
862
|
+
continue
|
|
863
|
+
try:
|
|
864
|
+
parts = shlex.split(raw_value)
|
|
865
|
+
except ValueError as exc:
|
|
866
|
+
return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}."
|
|
867
|
+
env[key] = parts[0] if parts else ""
|
|
868
|
+
return env, ""
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _wrapper_exec_command(text):
|
|
872
|
+
for raw_line in text.splitlines():
|
|
873
|
+
line = raw_line.strip()
|
|
874
|
+
if line.startswith("exec "):
|
|
875
|
+
return line[5:].strip()
|
|
876
|
+
return ""
|
|
877
|
+
|
|
878
|
+
|
|
695
879
|
def _tick_agent(agent_dir, now, runner):
|
|
696
880
|
meta = _read_json(agent_dir / "meta.json")
|
|
697
881
|
state = _read_json(agent_dir / "state.json")
|
|
@@ -927,6 +1111,7 @@ def _parse_agent_response(output):
|
|
|
927
1111
|
|
|
928
1112
|
def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
929
1113
|
messages = session.get("pending_messages") or []
|
|
1114
|
+
book_path = agent_dir / "AGENTBOOK.md"
|
|
930
1115
|
lines = [
|
|
931
1116
|
_AGENT_PROMPT,
|
|
932
1117
|
"",
|
|
@@ -935,16 +1120,25 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
|
935
1120
|
f"Stop policy: {meta['stop_policy']}",
|
|
936
1121
|
f"Heartbeat minutes: {meta['heartbeat_minutes']}",
|
|
937
1122
|
"",
|
|
938
|
-
"Original instructions:",
|
|
939
|
-
meta["prompt"],
|
|
940
|
-
"",
|
|
941
1123
|
f"Working directory: {meta['cwd']}",
|
|
942
|
-
f"Agentbook path: {
|
|
943
|
-
"
|
|
1124
|
+
f"Agentbook path: {book_path}",
|
|
1125
|
+
"Update the agentbook before you respond. Add or revise a dated note when "
|
|
1126
|
+
"something durable changed, when you corrected your picture, or when an "
|
|
1127
|
+
"assumption needs to be made explicit.",
|
|
1128
|
+
"If little has changed across wakes, treat that as evidence about the "
|
|
1129
|
+
"situation and reconsider your frame or next action instead of padding the "
|
|
1130
|
+
"book.",
|
|
1131
|
+
"If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.",
|
|
944
1132
|
]
|
|
945
|
-
|
|
1133
|
+
if _include_full_goal_prompt(state, session):
|
|
1134
|
+
lines.extend(["", "Original instructions:", meta["prompt"]])
|
|
1135
|
+
book = _ensure_agentbook_header(book_path, meta["prompt"], now)
|
|
946
1136
|
if book.strip():
|
|
947
|
-
lines.extend(["", "Agentbook (latest):",
|
|
1137
|
+
lines.extend(["", "Agentbook (header + latest notes):", _book_excerpt(book, _AGENTBOOK_BOOK_LIMIT, _AGENTBOOK_HEADER_LIMIT, _AGENTBOOK_TAIL_LIMIT)])
|
|
1138
|
+
raw_facts = _wake_facts(state)
|
|
1139
|
+
if raw_facts:
|
|
1140
|
+
lines.extend(["", "Raw harness facts:"])
|
|
1141
|
+
lines.extend(f"- {item}" for item in raw_facts)
|
|
948
1142
|
if messages:
|
|
949
1143
|
lines.extend(["", "Queued user messages:"])
|
|
950
1144
|
for message in messages:
|
|
@@ -1470,6 +1664,128 @@ def _read_text(path):
|
|
|
1470
1664
|
return ""
|
|
1471
1665
|
|
|
1472
1666
|
|
|
1667
|
+
def _include_full_goal_prompt(state, session):
|
|
1668
|
+
if not (state.get("last_success_at") or "").strip():
|
|
1669
|
+
return True
|
|
1670
|
+
return not ((session.get("thread_id") or state.get("thread_id") or "").strip())
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _wake_facts(state):
|
|
1674
|
+
facts = []
|
|
1675
|
+
previous_status = (state.get("activity") or "").strip()
|
|
1676
|
+
if previous_status:
|
|
1677
|
+
facts.append(f"Previous status: {previous_status}")
|
|
1678
|
+
previous_update = (state.get("update") or "").strip()
|
|
1679
|
+
if previous_update:
|
|
1680
|
+
facts.append(f"Previous update: {previous_update}")
|
|
1681
|
+
previous_error = (state.get("last_error") or "").strip()
|
|
1682
|
+
if previous_error:
|
|
1683
|
+
facts.append(f"Previous error: {previous_error}")
|
|
1684
|
+
return facts
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _ensure_agentbook_header(path, prompt, now):
|
|
1688
|
+
text = _read_text(path)
|
|
1689
|
+
if _agentbook_has_header(text):
|
|
1690
|
+
return text
|
|
1691
|
+
restored = _restore_agentbook_header(text, prompt, now)
|
|
1692
|
+
_write_text(path, restored)
|
|
1693
|
+
return restored
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _agentbook_has_header(text):
|
|
1697
|
+
text = str(text or "")
|
|
1698
|
+
required = (
|
|
1699
|
+
"## Purpose",
|
|
1700
|
+
"## Values",
|
|
1701
|
+
"## Original Goal",
|
|
1702
|
+
"## Standing Guidance",
|
|
1703
|
+
"## Working Notes",
|
|
1704
|
+
)
|
|
1705
|
+
return all(section in text for section in required)
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _restore_agentbook_header(text, prompt, now):
|
|
1709
|
+
restored = _agentbook_header(prompt).rstrip()
|
|
1710
|
+
existing = str(text or "").strip()
|
|
1711
|
+
if not existing:
|
|
1712
|
+
return restored + "\n"
|
|
1713
|
+
stamp = _agentbook_stamp(now)
|
|
1714
|
+
return "\n".join(
|
|
1715
|
+
[
|
|
1716
|
+
restored,
|
|
1717
|
+
"",
|
|
1718
|
+
f"### {stamp}",
|
|
1719
|
+
"System note:",
|
|
1720
|
+
"- The durable agentbook header was restored automatically on wake because one or more required sections were missing.",
|
|
1721
|
+
"",
|
|
1722
|
+
"Recovered notes:",
|
|
1723
|
+
existing,
|
|
1724
|
+
"",
|
|
1725
|
+
]
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _agentbook_stamp(now):
|
|
1730
|
+
if now is None:
|
|
1731
|
+
return ""
|
|
1732
|
+
return now.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _book_excerpt(text, limit, header_limit, tail_limit):
|
|
1736
|
+
text = str(text or "").strip()
|
|
1737
|
+
if not text:
|
|
1738
|
+
return ""
|
|
1739
|
+
if len(text) <= limit:
|
|
1740
|
+
return text
|
|
1741
|
+
header, notes = _split_book(text)
|
|
1742
|
+
header = _snippet(header, header_limit) if header else ""
|
|
1743
|
+
if not notes:
|
|
1744
|
+
return header or _tail_snippet(text, limit)
|
|
1745
|
+
marker = "\n\n[... older notes omitted ...]\n\n"
|
|
1746
|
+
if not header:
|
|
1747
|
+
return _latest_notes_snippet(notes, limit)
|
|
1748
|
+
remaining = max(0, limit - len(header) - len(marker))
|
|
1749
|
+
if remaining <= 0:
|
|
1750
|
+
return _snippet(header, limit)
|
|
1751
|
+
tail = _latest_notes_snippet(notes, min(tail_limit, remaining))
|
|
1752
|
+
if not tail:
|
|
1753
|
+
return header
|
|
1754
|
+
combined = header.rstrip() + marker + tail.lstrip()
|
|
1755
|
+
if len(combined) <= limit:
|
|
1756
|
+
return combined
|
|
1757
|
+
remaining = max(0, limit - len(header) - len(marker))
|
|
1758
|
+
return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip()
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _split_book(text):
|
|
1762
|
+
match = _DATED_NOTE_RE.search(text)
|
|
1763
|
+
if not match:
|
|
1764
|
+
return text.strip(), ""
|
|
1765
|
+
return text[: match.start()].strip(), text[match.start() :].strip()
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _latest_notes_snippet(text, limit):
|
|
1769
|
+
text = str(text or "").strip()
|
|
1770
|
+
if not text:
|
|
1771
|
+
return ""
|
|
1772
|
+
if len(text) <= limit:
|
|
1773
|
+
return text
|
|
1774
|
+
starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)]
|
|
1775
|
+
if not starts:
|
|
1776
|
+
return _tail_snippet(text, limit)
|
|
1777
|
+
start = starts[-1]
|
|
1778
|
+
for pos in reversed(starts[:-1]):
|
|
1779
|
+
candidate = text[pos:].strip()
|
|
1780
|
+
if len(candidate) > limit:
|
|
1781
|
+
break
|
|
1782
|
+
start = pos
|
|
1783
|
+
candidate = text[start:].strip()
|
|
1784
|
+
if len(candidate) <= limit:
|
|
1785
|
+
return candidate
|
|
1786
|
+
return _tail_snippet(candidate, limit)
|
|
1787
|
+
|
|
1788
|
+
|
|
1473
1789
|
def _snippet(text, limit):
|
|
1474
1790
|
if not text:
|
|
1475
1791
|
return ""
|
|
@@ -1481,6 +1797,17 @@ def _snippet(text, limit):
|
|
|
1481
1797
|
return text[: limit - 3] + "..."
|
|
1482
1798
|
|
|
1483
1799
|
|
|
1800
|
+
def _tail_snippet(text, limit):
|
|
1801
|
+
if not text:
|
|
1802
|
+
return ""
|
|
1803
|
+
text = str(text).strip()
|
|
1804
|
+
if len(text) <= limit:
|
|
1805
|
+
return text
|
|
1806
|
+
if limit <= 3:
|
|
1807
|
+
return text[-limit:]
|
|
1808
|
+
return "..." + text[-(limit - 3) :].lstrip()
|
|
1809
|
+
|
|
1810
|
+
|
|
1484
1811
|
def _strip_fence(text):
|
|
1485
1812
|
if not text.startswith("```"):
|
|
1486
1813
|
return text
|