python-agent-harness 1.5.0__py3-none-any.whl

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 (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,297 @@
1
+ """Session persistence: auto-save, LLM titles, restore.
2
+
3
+ Ported from gptel-agent-harness-session.el.
4
+
5
+ - Auto-save the conversation after each LLM response to
6
+ ~/.local/share/python-agent-harness/sessions/<name>_<YYMMDDHHMMSS>.md
7
+ with a trailing metadata block (;; Local Variables: ...).
8
+ - Async title generation from the first user message (title.md);
9
+ on success the file is renamed to <title>_<TS>.md.
10
+ - restore / restore-latest commands re-load a session.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from . import config
22
+
23
+
24
+ def session_dir() -> Path:
25
+ return config.SESSION_DIR / config.SESSION_SUBDIR
26
+
27
+
28
+ # The roles the save format delimits blocks with (`**<role>**: `).
29
+ SAVED_ROLES = ("user", "assistant", "system", "tool")
30
+
31
+
32
+ def split_role_header(line: str) -> tuple[str, str] | None:
33
+ """``(role, rest)`` when LINE is a ``**role**: `` block header, else None.
34
+
35
+ The single source of truth for the save format's block delimiter:
36
+ the renderer escapes what this would match and the parser splits on
37
+ exactly what this accepts, so the two can never drift apart.
38
+ """
39
+ if not line.startswith("**") or "**: " not in line:
40
+ return None
41
+ prefix, _, rest = line.partition("**: ")
42
+ role = prefix.strip("*").strip()
43
+ return (role, rest) if role in SAVED_ROLES else None
44
+
45
+
46
+ def _is_escapable(line: str) -> bool:
47
+ """Whether LINE is a block header, or an already-escaped one."""
48
+ return split_role_header(line.lstrip("\\")) is not None
49
+
50
+
51
+ def escape_role_headers(body: str) -> str:
52
+ r"""Backslash-escape message-body lines that look like block headers.
53
+
54
+ Blocks are delimited by ``**<role>**: `` at the start of a line, so a
55
+ message whose own text contains such a line — the agent explaining
56
+ this very format, or a pasted transcript — would otherwise be split
57
+ into extra (and misattributed) messages on restore. ``\**user**: ``
58
+ still reads as the literal text in markdown and is reversed by
59
+ `unescape_role_header`; already-escaped lines gain another backslash
60
+ so the round trip is exact at any nesting depth.
61
+ """
62
+ if "**" not in body:
63
+ return body
64
+ return "\n".join("\\" + ln if _is_escapable(ln) else ln for ln in body.split("\n"))
65
+
66
+
67
+ def unescape_role_header(line: str) -> str:
68
+ r"""Reverse one level of `escape_role_headers` for a single line.
69
+
70
+ Only lines that would otherwise be read as block headers are
71
+ touched, so a literal ``\**note**: `` in a message survives intact.
72
+ """
73
+ return line[1:] if line.startswith("\\") and _is_escapable(line) else line
74
+
75
+
76
+ def sanitize_title(title: str) -> str:
77
+ """Sanitize a generated title (mirrors the elisp semantics)."""
78
+ t = title.strip()
79
+ t = re.sub(r"[\n\r]+", " ", t)
80
+ t = t.strip('"')
81
+ t = re.sub(r"[/\\:*?\"<>|]", "-", t)
82
+ t = re.sub(r"[-_ ]+", "-", t)
83
+ t = t[:50]
84
+ t = re.sub(r"-+$", "", t)
85
+ return t
86
+
87
+
88
+ def title_from_filename(session_file: str) -> str | None:
89
+ """Derive a title from a session file name, or None."""
90
+ base = os.path.basename(session_file)
91
+ if base.endswith(".md"):
92
+ base = base[:-3]
93
+ m = re.match(r"(.+)_[0-9]{12}(?:-\d+)?$", base)
94
+ if not m:
95
+ return None
96
+ title = m.group(1).replace("-", " ")
97
+ if " " not in title:
98
+ return None # bare single-word project names rejected
99
+ return title
100
+
101
+
102
+ class SessionPersistence:
103
+ """Saves/restores sessions for one agent session."""
104
+
105
+ def __init__(
106
+ self,
107
+ project_dir: str,
108
+ model: str,
109
+ backend: str,
110
+ system_prompt: str | None = None,
111
+ temperature: float | None = None,
112
+ max_tokens: int | None = None,
113
+ tool_names: list[str] | None = None,
114
+ round_times: list[float] | None = None,
115
+ ) -> None:
116
+ self.project_dir = project_dir
117
+ self.model = model
118
+ self.backend = backend
119
+ self.system_prompt = system_prompt
120
+ self.temperature = temperature
121
+ self.max_tokens = max_tokens
122
+ self.tool_names = tool_names or []
123
+ self.title: str | None = None
124
+ self.file_path: str | None = None
125
+ self.title_pending = False
126
+ self._first_user_msg: str | None = None
127
+ # wall-clock start times of each round, persisted in the
128
+ # metadata block so restored sessions keep their round
129
+ # timestamps (populated by the TUI on each run)
130
+ self.round_times: list[float] = list(round_times) if round_times else []
131
+ # serializes save vs. apply_title: the rename must never
132
+ # interleave with a save's write+replace, or the conversation
133
+ # would split across two files (a titled stale file plus a
134
+ # fresh untitled one) when title generation races a new run's
135
+ # auto-save
136
+ self._io_lock = threading.Lock()
137
+
138
+ # -- file naming ----------------------------------------------------------
139
+ @staticmethod
140
+ def _unique_path(prefix: str) -> str:
141
+ """A session file path that does not already exist.
142
+
143
+ Same-second collisions (same project name, or two sessions given
144
+ the same title) get a numeric suffix instead of silently
145
+ overwriting an existing session file.
146
+ """
147
+ path = session_dir() / f"{prefix}.md"
148
+ n = 1
149
+ while path.exists():
150
+ path = session_dir() / f"{prefix}-{n}.md"
151
+ n += 1
152
+ return str(path)
153
+
154
+ def session_file(self) -> str | None:
155
+ if self.file_path:
156
+ return self.file_path
157
+ proj_name = os.path.basename(os.path.normpath(self.project_dir))
158
+ stamp = time.strftime("%y%m%d%H%M%S")
159
+ self.file_path = self._unique_path(f"{proj_name}_{stamp}")
160
+ return self.file_path
161
+
162
+ def remember_first_user_message(self, text: str) -> None:
163
+ if self._first_user_msg is None and len(text.strip()) > 3:
164
+ self._first_user_msg = text.strip()[:500]
165
+
166
+ def first_user_message(self) -> str | None:
167
+ return self._first_user_msg
168
+
169
+ # -- saving ---------------------------------------------------------------
170
+ def metadata_block(self) -> str:
171
+ lines = [";; Local Variables:"]
172
+ pairs = [
173
+ ("python-agent-harness--project-dir", self.project_dir),
174
+ ("gptel-model", self.model),
175
+ ("gptel--backend-name", self.backend),
176
+ ("gptel-system-prompt", self.system_prompt),
177
+ ("gptel-temperature", self.temperature),
178
+ ("gptel-max-tokens", self.max_tokens),
179
+ ]
180
+ for name, value in pairs:
181
+ if value is None:
182
+ continue
183
+ lines.append(f";; {name}: {value!r}")
184
+ if self.tool_names:
185
+ names = " ".join(f'"{n}"' for n in self.tool_names)
186
+ lines.append(f";; gptel--tool-names: ({names})")
187
+ if self.round_times:
188
+ stamps = " ".join(repr(float(t)) for t in self.round_times)
189
+ lines.append(f";; python-agent-harness--round-times: {stamps}")
190
+ lines.append(";; End:")
191
+ return "\n".join(lines)
192
+
193
+ def save(self, conversation_text: str) -> str | None:
194
+ # under the IO lock so a concurrent apply_title rename cannot
195
+ # land between the tmp write and the os.replace (see _io_lock)
196
+ with self._io_lock:
197
+ path = self.session_file()
198
+ if path is None:
199
+ return None
200
+ session_dir().mkdir(parents=True, exist_ok=True)
201
+ content = conversation_text.rstrip("\n") + "\n\n" + self.metadata_block() + "\n"
202
+ tmp = path + ".tmp"
203
+ Path(tmp).write_text(content, encoding="utf-8")
204
+ os.replace(tmp, path)
205
+ return path
206
+
207
+ def apply_title(self, title: str) -> None:
208
+ """Rename the session file to <title>_<TS>.md (never overwriting)."""
209
+ # under the IO lock: the rename must not interleave with a
210
+ # concurrent save's write+replace (see _io_lock)
211
+ with self._io_lock:
212
+ title = sanitize_title(title)
213
+ if not title:
214
+ return
215
+ if self.file_path and os.path.exists(self.file_path):
216
+ stamp = time.strftime("%y%m%d%H%M%S")
217
+ new_path = self._unique_path(f"{title}_{stamp}")
218
+ try:
219
+ os.replace(self.file_path, new_path)
220
+ self.file_path = str(new_path)
221
+ except OSError:
222
+ return
223
+ self.title = title
224
+
225
+ # -- restoring ---------------------------------------------------------------
226
+ @staticmethod
227
+ def parse_metadata(text: str) -> dict[str, str]:
228
+ """Parse the trailing ;; Local Variables: block (search from EOF).
229
+
230
+ Values are stored repr()-style (like the elisp %S printing);
231
+ parsing evaluates them with ast.literal_eval when possible.
232
+ """
233
+ marker = "\n;; Local Variables:\n"
234
+ idx = text.rfind(marker)
235
+ if idx == -1:
236
+ return {}
237
+ block = text[idx + len(marker) :]
238
+ end = block.find(";; End:")
239
+ if end != -1:
240
+ block = block[:end]
241
+ meta: dict[str, str] = {}
242
+ for line in block.splitlines():
243
+ line = line.strip()
244
+ if not line.startswith(";; "):
245
+ continue
246
+ body = line[3:]
247
+ if ":" not in body:
248
+ continue
249
+ name, _, value = body.partition(":")
250
+ meta[name.strip()] = _parse_metadata_value(value.strip())
251
+ return meta
252
+
253
+ @staticmethod
254
+ def strip_metadata(text: str) -> str:
255
+ marker = "\n;; Local Variables:\n"
256
+ idx = text.rfind(marker)
257
+ if idx == -1:
258
+ return text
259
+ end = text.find(";; End:", idx)
260
+ if end != -1:
261
+ end += len(";; End:")
262
+ return text[:idx] + text[end:]
263
+ return text[:idx]
264
+
265
+ @staticmethod
266
+ def latest_session() -> str | None:
267
+ d = session_dir()
268
+ if not d.is_dir():
269
+ return None
270
+ files = sorted(d.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
271
+ return str(files[0]) if files else None
272
+
273
+ @staticmethod
274
+ def list_sessions() -> list[str]:
275
+ d = session_dir()
276
+ if not d.is_dir():
277
+ return []
278
+ return sorted(
279
+ (str(p) for p in d.glob("*.md")),
280
+ key=lambda p: os.path.getmtime(p),
281
+ reverse=True,
282
+ )
283
+
284
+
285
+ def _parse_metadata_value(value: str) -> str:
286
+ """Parse a repr()-style metadata value, mirroring elisp read-from-string."""
287
+ import ast
288
+
289
+ try:
290
+ parsed = ast.literal_eval(value)
291
+ if isinstance(parsed, str):
292
+ return parsed
293
+ if isinstance(parsed, (list, tuple)):
294
+ return " ".join(str(x) for x in parsed)
295
+ return str(parsed)
296
+ except (ValueError, SyntaxError):
297
+ return value.strip("\"'")
@@ -0,0 +1,112 @@
1
+ """Build/plan mode management.
2
+
3
+ Ported from gptel-agent-harness.el's build/plan mode section.
4
+
5
+ - Plan mode: read-only except the per-session plan file
6
+ (<tmp>/python-agent-plans-<proj>-<rand>/PLAN.md).
7
+ - Switching to plan truncates the plan file and queues plan.md +
8
+ plan-mode.md (${planInfo} -> plan file path) for injection into the
9
+ next top-level request; switching back queues build-switch.md.
10
+ - Queued prompts are injected before the last user message (appended
11
+ when the last message is a tool result) and consumed exactly once.
12
+ - Sub-agent requests in plan mode get the plan-mode reminder once per
13
+ sub-agent loop.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import random
20
+ import string
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from . import config
25
+ from .models import AgentMode
26
+
27
+
28
+ def _plan_temp_dir() -> str:
29
+ """Reliable temp dir (first candidate that is set, else /tmp)."""
30
+ for d in (
31
+ os.environ.get("TMPDIR"),
32
+ os.environ.get("TMP"),
33
+ os.environ.get("TEMP"),
34
+ tempfile.gettempdir(),
35
+ ):
36
+ if d:
37
+ return os.path.abspath(d)
38
+ return "/tmp"
39
+
40
+
41
+ class PlanMode:
42
+ """Per-session plan-mode state."""
43
+
44
+ def __init__(self, project_dir: str) -> None:
45
+ self.mode = AgentMode.BUILD
46
+ self.project_dir = project_dir
47
+ self.plan_file: str | None = None
48
+ self.pending_prompts: list[str] = []
49
+
50
+ # -- plan file lifecycle --------------------------------------------------
51
+ def plan_temp_dir(self) -> str:
52
+ return _plan_temp_dir()
53
+
54
+ def plan_file_path(self) -> str:
55
+ if self.plan_file:
56
+ return self.plan_file
57
+ proj_name = os.path.basename(os.path.normpath(self.project_dir))
58
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
59
+ d = os.path.join(self.plan_temp_dir(), f"python-agent-plans-{proj_name}-{suffix}")
60
+ return os.path.join(d, config.PLAN_FILE_NAME)
61
+
62
+ def ensure_plan_file(self) -> str:
63
+ path = self.plan_file_path()
64
+ os.makedirs(os.path.dirname(path), exist_ok=True)
65
+ if not os.path.exists(path):
66
+ Path(path).write_text("", encoding="utf-8")
67
+ self.plan_file = path
68
+ return path
69
+
70
+ def cleanup_plan_file(self) -> None:
71
+ if not self.plan_file:
72
+ return
73
+ try:
74
+ if os.path.exists(self.plan_file):
75
+ os.remove(self.plan_file)
76
+ d = os.path.dirname(self.plan_file)
77
+ if os.path.isdir(d) and not os.listdir(d):
78
+ os.rmdir(d)
79
+ except OSError:
80
+ pass
81
+ self.plan_file = None
82
+
83
+ # -- mode switching ---------------------------------------------------------
84
+ def set_mode(self, mode: AgentMode, prompts: dict[str, str]) -> None:
85
+ """Set the mode; PROMPTS maps 'plan'/'plan-mode'/'build-switch' to text."""
86
+ if mode == AgentMode.PLAN:
87
+ plan_file = self.ensure_plan_file()
88
+ # start each planning round from an empty file
89
+ if self.plan_file and os.path.exists(plan_file):
90
+ Path(plan_file).write_text("", encoding="utf-8")
91
+ self.mode = mode
92
+ self.plan_file = plan_file
93
+ self.pending_prompts = [
94
+ prompts["plan"],
95
+ prompts["plan-mode"].replace("${planInfo}", plan_file),
96
+ ]
97
+ else:
98
+ self.mode = AgentMode.BUILD
99
+ self.pending_prompts = [prompts["build-switch"]]
100
+
101
+ def consume_prompts(self) -> list[str]:
102
+ prompts = self.pending_prompts
103
+ self.pending_prompts = []
104
+ return prompts
105
+
106
+ # -- helpers ----------------------------------------------------------------
107
+ @property
108
+ def is_plan(self) -> bool:
109
+ return self.mode == AgentMode.PLAN
110
+
111
+ def plan_reminder(self) -> str:
112
+ return config.PLAN_MODE_SUBAGENT_REMINDER % (self.plan_file or self.plan_file_path())