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,147 @@
1
+ """Tool base classes and the tool registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any
8
+
9
+ from ..models import ToolSpec
10
+
11
+
12
+ class PendingToolResult:
13
+ """Handle for an asynchronous tool result (mirrors ``:async t``).
14
+
15
+ An async tool's ``run`` returns this handle instead of a string: it
16
+ starts its background work (e.g. a spawned process) and returns
17
+ immediately, then delivers the final result string later via
18
+ ``deliver`` — so the wait never blocks the sequential tool loop.
19
+
20
+ ``deliver`` is idempotent (first delivery wins, late duplicates are
21
+ no-ops — mirroring the gptel-agent FSM's idempotent-result advice);
22
+ ``wait`` blocks until the result has been delivered.
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ self._event = threading.Event()
27
+ self._result: str | None = None
28
+
29
+ def deliver(self, result: str) -> None:
30
+ if not self._event.is_set():
31
+ self._result = result
32
+ self._event.set()
33
+
34
+ def wait(self) -> str:
35
+ self._event.wait()
36
+ return self._result or ""
37
+
38
+
39
+ class ToolContext:
40
+ """Runtime context handed to tools.
41
+
42
+ Tools may call back into the session for user questions,
43
+ plan-mode checks, and sub-agent delegation. All methods
44
+ proxy to the session when present; defaults are safe no-ops.
45
+ """
46
+
47
+ def __init__(self, session: Any = None) -> None:
48
+ self.session = session
49
+
50
+ @property
51
+ def cwd(self) -> str:
52
+ return self.session.project_dir if self.session else "."
53
+
54
+ def ask_questions(self, questions: list[dict]) -> str:
55
+ if self.session and hasattr(self.session, "ask_questions"):
56
+ return self.session.ask_questions(questions)
57
+ return "Unanswered"
58
+
59
+ def record_diff(self, diff_text: str) -> None:
60
+ """Attach a unified diff to the currently-executing tool call."""
61
+ if self.session and hasattr(self.session, "record_diff"):
62
+ self.session.record_diff(diff_text)
63
+
64
+ def update_todos(self, todos: list[dict]) -> None:
65
+ if self.session and hasattr(self.session, "update_todos"):
66
+ self.session.update_todos(todos)
67
+
68
+ def find_skill(self, name: str) -> str | None:
69
+ if self.session and hasattr(self.session, "find_skill"):
70
+ return self.session.find_skill(name)
71
+ return None
72
+
73
+ def run_subagent(self, subagent_type: str, description: str, prompt: str) -> str:
74
+ if self.session and hasattr(self.session, "run_subagent"):
75
+ return self.session.run_subagent(subagent_type, description, prompt)
76
+ return f"Error: Task {description!r} returned an unexpected response — no session"
77
+
78
+ def plan_exit(self) -> str:
79
+ if self.session and hasattr(self.session, "plan_exit"):
80
+ return self.session.plan_exit()
81
+ return "Not in plan mode; PlanExit has no effect. Continue as normal."
82
+
83
+ @property
84
+ def cancel_event(self) -> Any:
85
+ """Session cancel event (set when the user presses Ctrl-C)."""
86
+ if self.session and hasattr(self.session, "cancel_event"):
87
+ return self.session.cancel_event
88
+ return None
89
+
90
+
91
+ class Tool(ABC):
92
+ name: str = ""
93
+ description: str = ""
94
+ # NB: Tool is an ABC, not a dataclass, so this is a plain class-level
95
+ # default (never mutated in place — every concrete tool overrides it
96
+ # with its own schema). It must be a real dict: a dataclasses.field()
97
+ # sentinel here would silently become the "parameters" of any tool
98
+ # that forgot to override it and then fail JSON serialization.
99
+ parameters: dict[str, Any] = {}
100
+
101
+ @abstractmethod
102
+ def run(self, args: dict[str, Any], ctx: ToolContext) -> str | PendingToolResult:
103
+ """Execute the tool and return the result string.
104
+
105
+ Async tools return a ``PendingToolResult`` instead (see
106
+ ``Bash``): the background work is spawned here and the final
107
+ string is delivered later via ``PendingToolResult.deliver``.
108
+ """
109
+
110
+ def spec(self) -> ToolSpec:
111
+ return ToolSpec(
112
+ name=self.name,
113
+ description=self.description,
114
+ parameters=self.parameters,
115
+ )
116
+
117
+
118
+ class Registry:
119
+ def __init__(self) -> None:
120
+ self._tools: dict[str, Tool] = {}
121
+ self._lock = threading.Lock()
122
+
123
+ def register(self, tool: Tool) -> None:
124
+ with self._lock:
125
+ self._tools[tool.name] = tool
126
+
127
+ def unregister(self, name: str) -> None:
128
+ with self._lock:
129
+ self._tools.pop(name, None)
130
+
131
+ def get(self, name: str) -> Tool | None:
132
+ return self._tools.get(name)
133
+
134
+ def specs(self, names: list[str] | None = None) -> list[ToolSpec]:
135
+ with self._lock:
136
+ items = list(self._tools.items())
137
+ wanted = set(names) if names is not None else {n for n, _ in items}
138
+ return [t.spec() for name, t in items if name in wanted]
139
+
140
+ def execute(self, name: str, args: dict[str, Any], ctx: ToolContext) -> str | PendingToolResult:
141
+ tool = self._tools.get(name)
142
+ if tool is None:
143
+ return f"Error: unknown tool {name!r}"
144
+ try:
145
+ return tool.run(args, ctx)
146
+ except Exception as e: # noqa: BLE001 - errors become tool results
147
+ return f"Error: tool {name} failed — {e}"
@@ -0,0 +1,298 @@
1
+ """Bash tool.
2
+
3
+ Asynchronous (mirrors ``:async t`` in gptel-agent-tools): ``run``
4
+ spawns the process and returns a ``PendingToolResult`` immediately; a
5
+ background thread collects the output and delivers it when the process
6
+ exits. A long-running command therefore never blocks the parent's
7
+ sequential tool loop — it runs concurrently with sibling async tools
8
+ (Agent) while sync tools execute one at a time.
9
+
10
+ A session cancel (Ctrl-C) kills the process group and delivers a
11
+ cancelled error.
12
+
13
+ Output is read incrementally with a bounded buffer (head + tail), so a
14
+ huge stream (e.g. ``cat`` of a multi-GB log) can never exhaust memory,
15
+ and delivery never waits on a detached child that keeps the stdout
16
+ pipe open after the shell has exited.
17
+
18
+ Normal completion appends ``Exit code: N`` (N negative = killed by a
19
+ signal). A command that produces no output for ``BASH_TIMEOUT_SILENCE``
20
+ seconds is killed (SIGTERM then SIGKILL) and reported as timed out;
21
+ ``BASH_TIMEOUT_MAX`` optionally caps the total runtime. Ctrl-C kills
22
+ the process group immediately.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import codecs
28
+ import contextlib
29
+ import os
30
+ import select
31
+ import signal
32
+ import subprocess
33
+ import threading
34
+ import time
35
+ from collections import deque
36
+
37
+ from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
38
+ from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
39
+ from .base import PendingToolResult, Tool, ToolContext
40
+
41
+ # Tail lines kept after truncation (the head budget is derived from
42
+ # MAX_OUTPUT_CHARS, shared with the filesystem spool threshold).
43
+ _TAIL_LINES = 50 # lines kept from the tail after truncation
44
+ _READ_CHUNK = 64 * 1024 # bytes read from the pipe per iteration
45
+ _POLL_INTERVAL = 0.02 # seconds between cancel/exit checks
46
+ _DRAIN_GRACE = 0.25 # seconds to keep reading after the process exits
47
+
48
+
49
+ def _kill_pgid(pgid: int) -> None:
50
+ """Kill the process group PGID, ignoring "already gone" errors.
51
+
52
+ The group id is captured at spawn (with ``start_new_session=True``
53
+ the child is the group leader, so its pid IS the pgid): it must
54
+ never be resolved at kill time via ``os.getpgid`` — by then the
55
+ shell may already be dead while a detached child that keeps the
56
+ stdout pipe open is still running in the group.
57
+ """
58
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
59
+ os.killpg(pgid, signal.SIGKILL)
60
+
61
+
62
+ def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
63
+ """SIGTERM the process group; SIGKILL if it is still alive 2s later.
64
+
65
+ Used for timeouts so shells/compilers get a chance to clean up
66
+ children before the hard kill.
67
+ """
68
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
69
+ os.killpg(pgid, signal.SIGTERM)
70
+ try:
71
+ proc.wait(timeout=2)
72
+ except subprocess.TimeoutExpired:
73
+ _kill_pgid(pgid)
74
+
75
+
76
+ def _timeout_message(out: str, silence: bool) -> str:
77
+ """Report a timed-out command, preserving any output so far."""
78
+ if silence:
79
+ timeout = BASH_TIMEOUT_SILENCE or 0.0
80
+ reason = f"no output for {timeout:.0f}s"
81
+ else:
82
+ timeout = BASH_TIMEOUT_MAX or 0.0
83
+ reason = f"exceeded the {timeout:.0f}s maximum"
84
+ out = out.rstrip("\n")
85
+ suffix = f"Error: Bash command timed out ({reason})."
86
+ return f"{out}\n\n{suffix}" if out else suffix
87
+
88
+
89
+ def _append_exit_code(out: str, proc: subprocess.Popen) -> str:
90
+ """Append ``Exit code: N`` (N negative = killed by a signal).
91
+
92
+ The exit-code line is added AFTER truncation, so it is always the
93
+ last line and survives the head+tail retention.
94
+ """
95
+ try:
96
+ rc = proc.wait(timeout=2)
97
+ except subprocess.TimeoutExpired:
98
+ return out # still alive; nothing useful to report
99
+ if out and not out.endswith("\n"):
100
+ out += "\n"
101
+ return f"{out}Exit code: {rc}"
102
+
103
+
104
+ def _assemble_truncated(head: str, tail: deque[str]) -> str:
105
+ """Assemble a truncated output within the cap.
106
+
107
+ ``head`` holds the first ``_MAX_OUTPUT`` chars, ``tail`` the last
108
+ ``_TAIL_LINES`` lines (each already line-capped). The tail is
109
+ preferred: as many trailing lines as fit are kept and the head gets
110
+ the remaining budget, so the delivered string never exceeds
111
+ ``_MAX_OUTPUT`` (plus the truncation notice).
112
+ """
113
+ notice = f"... [truncated: output exceeded {_MAX_OUTPUT} chars] ..."
114
+ budget = _MAX_OUTPUT - len(notice) - 4 # room for the "\n\n" separators
115
+ tail_parts: list[str] = []
116
+ used = 0
117
+ for line in reversed(tail):
118
+ cost = len(line) + (1 if tail_parts else 0)
119
+ if used + cost > budget:
120
+ break
121
+ tail_parts.append(line)
122
+ used += cost
123
+ head = head[: max(0, budget - used)]
124
+ out = f"{head}\n\n{notice}"
125
+ if tail_parts:
126
+ out += "\n\n" + "\n".join(reversed(tail_parts))
127
+ return out
128
+
129
+
130
+ def _collect_output(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
131
+ """Read PROC's merged output incrementally; return (text, status).
132
+
133
+ Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
134
+ ``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars) and
135
+ the tail (last ``_TAIL_LINES`` lines) and discards the middle, so
136
+ memory stays bounded no matter how much the process writes. The
137
+ read loop is poll-based: a cancel is noticed promptly, a process
138
+ silent for ``BASH_TIMEOUT_SILENCE`` seconds (or running past
139
+ ``BASH_TIMEOUT_MAX``) is reported as timed out, and a process that
140
+ has exited is only drained for ``_DRAIN_GRACE`` seconds — a
141
+ detached child holding the pipe open can never wedge delivery.
142
+ """
143
+ stdout = proc.stdout
144
+ if stdout is None: # unreachable (stdout=PIPE), kept for the type checker
145
+ return "", "ok"
146
+ fd = stdout.fileno()
147
+ os.set_blocking(fd, False)
148
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
149
+ head: list[str] = []
150
+ head_len = 0
151
+ tail: deque[str] = deque(maxlen=_TAIL_LINES)
152
+ pending_line = ""
153
+ total = 0
154
+ exited = False
155
+ drain_until: float | None = None
156
+ start = time.monotonic()
157
+ last_output = start
158
+
159
+ def finish() -> str:
160
+ nonlocal pending_line
161
+ if pending_line:
162
+ if len(pending_line) > _MAX_OUTPUT:
163
+ pending_line = pending_line[:_MAX_OUTPUT]
164
+ tail.append(pending_line)
165
+ head_text = "".join(head)
166
+ if total > _MAX_OUTPUT:
167
+ return _assemble_truncated(head_text, tail)
168
+ return head_text
169
+
170
+ while True:
171
+ if cancel is not None and cancel.is_set():
172
+ return "", "cancelled"
173
+ now = time.monotonic()
174
+ if not exited:
175
+ if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
176
+ return finish(), "timeout_silence"
177
+ if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
178
+ return finish(), "timeout_max"
179
+ if exited and drain_until is not None and now >= drain_until:
180
+ break
181
+ readable, _, _ = select.select([fd], [], [], _POLL_INTERVAL)
182
+ if not readable:
183
+ if not exited and proc.poll() is not None:
184
+ exited = True
185
+ drain_until = time.monotonic() + _DRAIN_GRACE
186
+ continue
187
+ try:
188
+ raw = os.read(fd, _READ_CHUNK)
189
+ except BlockingIOError:
190
+ continue
191
+ except OSError:
192
+ break
193
+ if not raw:
194
+ break # EOF: every writer closed the pipe
195
+ chunk = decoder.decode(raw)
196
+ total += len(chunk)
197
+ last_output = time.monotonic()
198
+ if head_len < _MAX_OUTPUT:
199
+ take = chunk[: _MAX_OUTPUT - head_len]
200
+ head.append(take)
201
+ head_len += len(take)
202
+ parts = chunk.split("\n")
203
+ parts[0] = pending_line + parts[0]
204
+ pending_line = parts.pop()
205
+ for line in parts:
206
+ if len(line) > _MAX_OUTPUT:
207
+ line = line[:_MAX_OUTPUT]
208
+ tail.append(line)
209
+ return finish(), "ok"
210
+
211
+
212
+ class Bash(Tool):
213
+ name = "Bash"
214
+ _timeout_silence = BASH_TIMEOUT_SILENCE
215
+ _timeout_note = (
216
+ f"A command silent for {_timeout_silence:.0f}s is killed and reported as timed out. "
217
+ if _timeout_silence is not None
218
+ else ""
219
+ )
220
+ description = (
221
+ "Execute a shell command. Returns stdout followed by 'Exit code: N' "
222
+ "(N is the command's exit status; negative means killed by a signal). "
223
+ + _timeout_note
224
+ + "A session cancel (Ctrl-C) kills the process."
225
+ )
226
+ parameters = {
227
+ "type": "object",
228
+ "properties": {
229
+ "command": {"type": "string", "description": "The shell command to run"},
230
+ },
231
+ "required": ["command"],
232
+ }
233
+
234
+ def run(self, args: dict, ctx: ToolContext) -> str | PendingToolResult:
235
+ command = args["command"]
236
+ return self._execute(command, ctx)
237
+
238
+ def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
239
+ cancel = ctx.cancel_event
240
+ if cancel is not None and cancel.is_set():
241
+ # Ctrl-C already pending: do not spawn a process that would
242
+ # be killed moments later.
243
+ return "Error: Bash command cancelled."
244
+ try:
245
+ proc = subprocess.Popen(
246
+ command,
247
+ shell=True,
248
+ stdin=subprocess.DEVNULL,
249
+ stdout=subprocess.PIPE,
250
+ stderr=subprocess.STDOUT,
251
+ bufsize=0,
252
+ cwd=ctx.cwd,
253
+ start_new_session=True,
254
+ )
255
+ except OSError as e:
256
+ return f"Error: {e}"
257
+
258
+ # start_new_session=True makes the child the session/group
259
+ # leader, so its pid IS the group id — captured once here,
260
+ # never resolved again at kill time.
261
+ pgid = proc.pid
262
+ pending = PendingToolResult()
263
+
264
+ def deliverer() -> None:
265
+ """Collect output; deliver it once the process exits.
266
+
267
+ The kill happens HERE (not in a separate watcher thread):
268
+ the collector is the thread that observed the condition, so
269
+ there is no race window in which a watcher exits without
270
+ killing and the process group survives. Cancel is an
271
+ immediate SIGKILL; a timeout kills gracefully (SIGTERM,
272
+ then SIGKILL after 2s) so children get a chance to clean up.
273
+ """
274
+ try:
275
+ out, status = _collect_output(proc, cancel)
276
+ except Exception as e: # noqa: BLE001 - delivered as an error string
277
+ out = f"Error: Bash failed — {e}"
278
+ else:
279
+ if status == "cancelled":
280
+ _kill_pgid(pgid)
281
+ out = "Error: Bash command cancelled."
282
+ elif status == "timeout_silence":
283
+ _kill_graceful(pgid, proc)
284
+ out = _timeout_message(out, silence=True)
285
+ elif status == "timeout_max":
286
+ _kill_graceful(pgid, proc)
287
+ out = _timeout_message(out, silence=False)
288
+ elif status == "ok":
289
+ out = _append_exit_code(out, proc)
290
+ pending.deliver(out)
291
+ with contextlib.suppress(Exception):
292
+ if proc.stdout is not None:
293
+ proc.stdout.close()
294
+ with contextlib.suppress(Exception):
295
+ proc.wait(timeout=2) # reap (bounded; never wedges)
296
+
297
+ threading.Thread(target=deliverer, daemon=True).start()
298
+ return pending