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.
Files changed (33) hide show
  1. {codexapi-0.12.2 → codexapi-0.12.6}/PKG-INFO +21 -3
  2. codexapi-0.12.2/src/codexapi.egg-info/PKG-INFO → codexapi-0.12.6/README.md +20 -17
  3. {codexapi-0.12.2 → codexapi-0.12.6}/pyproject.toml +1 -1
  4. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/__init__.py +3 -1
  5. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/agent.py +23 -0
  6. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/agents.py +355 -28
  7. codexapi-0.12.6/src/codexapi/async_agent.py +439 -0
  8. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/cli.py +27 -15
  9. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/lead.py +80 -79
  10. codexapi-0.12.2/README.md → codexapi-0.12.6/src/codexapi.egg-info/PKG-INFO +35 -2
  11. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/SOURCES.txt +2 -0
  12. {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_agents.py +203 -3
  13. codexapi-0.12.6/tests/test_async_agent.py +158 -0
  14. {codexapi-0.12.2 → codexapi-0.12.6}/LICENSE +0 -0
  15. {codexapi-0.12.2 → codexapi-0.12.6}/setup.cfg +0 -0
  16. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/__main__.py +0 -0
  17. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/foreach.py +0 -0
  18. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/gh_integration.py +0 -0
  19. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/pushover.py +0 -0
  20. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/ralph.py +0 -0
  21. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/rate_limits.py +0 -0
  22. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/science.py +0 -0
  23. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/task.py +0 -0
  24. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/taskfile.py +0 -0
  25. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi/welfare.py +0 -0
  26. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/dependency_links.txt +0 -0
  27. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/entry_points.txt +0 -0
  28. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/requires.txt +0 -0
  29. {codexapi-0.12.2 → codexapi-0.12.6}/src/codexapi.egg-info/top_level.txt +0 -0
  30. {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_agent_backend.py +0 -0
  31. {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_rate_limits.py +0 -0
  32. {codexapi-0.12.2 → codexapi-0.12.6}/tests/test_science.py +0 -0
  33. {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.2
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 and must be updated before the agent responds. Use
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 and must be updated before the agent responds. Use
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.12.2"
7
+ version = "0.12.6"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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.2"
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 a long-term codexapi agent. You are being woken up to make progress "
35
- "on an ongoing job. Be independent and practical. Manage work and follow "
36
- "through. Use codexapi task or codexapi science when you want a separate "
37
- "coding worker. If you need the user's attention, put a short message in the "
38
- "reply field. Put a short first-person turn summary in the update field. If "
39
- "something is urgent and should send Pushover, put it in the notify field. "
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
- host = hostname or current_hostname()
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": backend or os.environ.get("CODEXAPI_BACKEND", "codex"),
240
+ "backend": backend_name,
155
241
  "yolo": bool(yolo),
156
242
  "flags": flags or "",
157
243
  "cwd": cwd,
158
- "env": _capture_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", _AGENTBOOK_TEMPLATE)
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 cron_installed(home=None, hostname=None):
612
- """Return whether this home and host have an installed scheduler hook."""
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
- installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines())
619
- return installed and wrapper.exists()
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: {agent_dir / 'AGENTBOOK.md'}",
943
- "Append a dated note to the agentbook before you respond.",
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
- book = _read_text(agent_dir / "AGENTBOOK.md")
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):", _snippet(book, 3000)])
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