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,385 @@
1
+ """Core TUI class — the coordinator that owns shared state, the main
2
+ event loop, agent run lifecycle, and session callbacks.
3
+
4
+ Rendering, input, and slash commands are mixed in from their respective
5
+ modules; this module provides the glue: ``__init__``, ``run``,
6
+ ``_start_agent``, ``_run_live``, ``_run_dumb``, ``_run_agent``, and the
7
+ session callbacks (``_on_delta``, ``_on_notify``, ``_on_log``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from prompt_toolkit.history import FileHistory
19
+ from rich import box
20
+ from rich.console import Console
21
+ from rich.live import Live
22
+ from rich.panel import Panel
23
+ from rich.text import Text
24
+
25
+ from .. import config
26
+ from ..agent import run_agent_loop
27
+ from ..models import Message
28
+ from ..session import Session
29
+ from .commands import CommandMixin
30
+ from .input import InputMixin, SlashCompleter, UiQuestion, _history_path, _make_prompt_session
31
+ from .render import RenderMixin
32
+
33
+
34
+ class Tui(RenderMixin, InputMixin, CommandMixin):
35
+ """Interactive Rich TUI for the agent harness.
36
+
37
+ Layout: conversation panel + status bar + input line. The agent loop
38
+ runs in a worker thread; the main thread renders with rich Live and
39
+ services interactive questions (Question tool, PlanExit confirmation).
40
+ """
41
+
42
+ def __init__(self, session: Session, console: Console | None = None) -> None:
43
+ self.session = session
44
+ self.console = console or Console()
45
+ self.stream_text = ""
46
+ self.lock = threading.Lock()
47
+ self.question: UiQuestion | None = None
48
+ self.agent_running = False
49
+ self.status = " idle"
50
+ self._current_tool = ""
51
+ self.run_seq = 0
52
+ self._restore: Callable[[], None] | None = None
53
+ self.conversation_history: list[Message] = []
54
+ # Index into ``session.last_messages`` where the current round
55
+ # begins: the live panel renders only from here on (the latest
56
+ # round of interactions), while the end-of-run scrollback dump
57
+ # prints the full conversation. 0 means "show everything" —
58
+ # the default until the first run sets a boundary.
59
+ self.round_start = 0
60
+ # Text the user submitted for the current round, shown as a live
61
+ # user row until it is mirrored into ``session.last_messages``
62
+ # (which only happens once the first assistant response lands).
63
+ self.round_user_text = ""
64
+ self._data_event = threading.Event()
65
+ self._history_cache: list[Any] | None = None
66
+ self._history_dirty = True
67
+ # wall-clock start of each agent run (one per user round), used
68
+ # by the end-of-run dump to timestamp the round separators
69
+ self._round_times: list[float] = []
70
+ # wall-clock start of the current run, used to report the total
71
+ # time spent once the run finishes
72
+ self._run_start: float | None = None
73
+ self.prompt_session = _make_prompt_session(
74
+ FileHistory(_history_path()),
75
+ SlashCompleter(lambda: str(self.session.project_dir)),
76
+ )
77
+
78
+ session.on_delta = self._on_delta
79
+ session.notify_fn = self._on_notify
80
+ session.log_fn = self._on_log
81
+ session.confirm_fn = self._ui_confirm
82
+ session.ask_fn = self._ui_ask
83
+
84
+ # ------------------------------------------------------------------
85
+ # session callbacks (called from the worker thread)
86
+ # ------------------------------------------------------------------
87
+ def _on_delta(self, text: str) -> None:
88
+ with self.lock:
89
+ self.stream_text += text
90
+ # bound the buffer so every frame's tail-slicing is
91
+ # constant-time, no matter how long the generation runs
92
+ if len(self.stream_text) > 100_000:
93
+ self.stream_text = self.stream_text[-100_000:]
94
+ # wake the render loop immediately: streaming text pushes the
95
+ # display without waiting for the next fixed tick
96
+ self._data_event.set()
97
+
98
+ def _on_notify(self, kind: str, data: Any = None) -> None:
99
+ if kind == "tool_start":
100
+ # Tool execution is starting: clear the stale stream text
101
+ # (the assistant message is committed to messages and will
102
+ # appear in history once delivered) and show which tools are
103
+ # running, so the display stays alive during long operations.
104
+ with self.lock:
105
+ self.stream_text = ""
106
+ names = data if isinstance(data, list) else []
107
+ label = ", ".join(names) if names else "tools"
108
+ self._current_tool = label
109
+ self.status = f" ⏳ {label}"
110
+ self._history_dirty = True
111
+ elif kind == "tool_running":
112
+ # Per-tool notification: update the current tool name shown
113
+ # beside the spinner as each sync tool starts executing.
114
+ name = data if isinstance(data, str) else ""
115
+ self._current_tool = name
116
+ self.status = f" ⏳ {name}" if name else " ⏳ tools"
117
+ elif kind == "tools":
118
+ self._current_tool = ""
119
+ self.status = " running tools"
120
+ # tool round finished: session.last_messages now contains the
121
+ # tool-call + result rows — rebuild the cached history so
122
+ # they show up live instead of after the run ends
123
+ self._history_dirty = True
124
+ elif kind == "compact":
125
+ self.status = " compacted"
126
+ self._history_dirty = True
127
+ elif kind == "retry":
128
+ # A connection error mid-stream: the client discarded the
129
+ # partial response and is retrying on a fresh connection —
130
+ # drop the partial stream text so the restarted stream
131
+ # doesn't duplicate it on screen.
132
+ with self.lock:
133
+ self.stream_text = ""
134
+ self.status = " connection lost — retrying"
135
+ elif kind == "todos":
136
+ # TodoWrite updated the task list: the cached history rows
137
+ # (which include the Todos panel) must be rebuilt
138
+ self._history_dirty = True
139
+ elif kind == "error":
140
+ self.status = " error"
141
+ elif kind == "save-error":
142
+ self.status = " auto-save failed"
143
+ else:
144
+ self.status = " running"
145
+ self._data_event.set()
146
+
147
+ def _on_log(self, msg: str) -> None:
148
+ self.status = f" {msg}"
149
+
150
+ # ------------------------------------------------------------------
151
+ # main loop
152
+ # ------------------------------------------------------------------
153
+ def run(self) -> None:
154
+ self.console.print(
155
+ Panel(
156
+ Text.from_markup(
157
+ "[bold]Commands:[/bold] /plan /build /init /review /explain "
158
+ "/compact /save /summary /sessions /restore /help /exit\n\n"
159
+ "Ctrl-C cancels the current execution (the app stays open); "
160
+ "Ctrl-D or /exit quits.\n"
161
+ "Type a message — Enter for a new line, Esc then Enter "
162
+ "(or Alt+Enter) to submit. Up/Down recall history.\n\n"
163
+ "[dim]Type [bold]/help[/bold] for the full command reference.[/dim]"
164
+ ),
165
+ title="[bold cyan]python-agent-harness — interactive AI coding agent[/bold cyan]",
166
+ border_style="cyan",
167
+ box=box.ROUNDED,
168
+ )
169
+ )
170
+ if config.LLM_LOG_ENABLED:
171
+ self.console.print(f"[dim]LLM logs: {self.session.client.log_path}[/dim]")
172
+ while True:
173
+ try:
174
+ if self.question is not None:
175
+ self._ask_question_blocking()
176
+ continue
177
+ self.console.print(self._status_bar())
178
+ self._flush()
179
+ text = self._read_multiline()
180
+ if text is None:
181
+ break
182
+ if not text.strip():
183
+ continue
184
+ if text.startswith("/"):
185
+ if self._handle_slash(text):
186
+ break
187
+ continue
188
+ self._start_agent(text)
189
+ except KeyboardInterrupt:
190
+ # stray Ctrl-C outside input/execution: stay in the app
191
+ self.console.print("[dim]cancelled — Ctrl-D or /exit to quit[/dim]")
192
+
193
+ def _start_agent(
194
+ self,
195
+ text: str,
196
+ system: str | None = None,
197
+ restore: Callable[[], None] | None = None,
198
+ ) -> None:
199
+ """Run the agent loop on TEXT in a worker thread.
200
+
201
+ SYSTEM overrides the session's system prompt for this run only.
202
+ RESTORE (if given) runs when the run finishes — used by the
203
+ slash commands to put back state they borrowed (e.g. project_dir).
204
+ """
205
+ self.stream_text = ""
206
+ self.status = " running"
207
+ self._current_tool = ""
208
+ # Mark where this round begins in the shared history: the live
209
+ # panel renders only from here on (the latest round), while the
210
+ # end-of-run dump prints the full conversation. Captured before
211
+ # the run so it points just past the previous round's messages —
212
+ # the new user message will be the first mirrored row.
213
+ self.round_start = len(self.session.last_messages or [])
214
+ self.round_user_text = text
215
+ self._round_times.append(time.time())
216
+ self._run_start = time.time()
217
+ # keep the persisted metadata in sync so auto-save /save
218
+ # capture the round timestamps (restore reads them back)
219
+ self.session.store.round_times = list(self._round_times)
220
+ # A new top-level run starts here: drop any todo list left over
221
+ # from a previous run so a finished task's todos don't stay
222
+ # pinned into the next task.
223
+ self.session.clear_todos()
224
+ # A new top-level run starts here: invalidate any worker still
225
+ # unwinding from a previous run — from this point on it is stale
226
+ # and must never touch shared state. Bump before clearing the
227
+ # event so there is no instant where an old worker sees "not
228
+ # cancelled".
229
+ self.session.run_generation += 1
230
+ self.session.cancel_event.clear()
231
+ self._data_event.clear()
232
+ self._history_dirty = True
233
+ self.run_seq += 1
234
+ seq = self.run_seq
235
+ self._restore = restore
236
+ self.agent_running = True
237
+ worker = threading.Thread(
238
+ target=self._run_agent, args=(text, seq, system, restore), daemon=True
239
+ )
240
+ worker.start()
241
+ cancelled = False
242
+ try:
243
+ if self.console.is_dumb_terminal:
244
+ cancelled = self._run_dumb(worker)
245
+ else:
246
+ cancelled = self._run_live(worker)
247
+ except KeyboardInterrupt:
248
+ # Ctrl-C during execution: cancel the run, keep the app open.
249
+ # The worker is a daemon and cancel-aware; don't join it — a
250
+ # hung HTTP read may take a while, and the UI must return to
251
+ # the input prompt immediately.
252
+ cancelled = True
253
+ self.session.cancel()
254
+ if self.question is not None:
255
+ # a pending question wedges the worker in its wait: the
256
+ # run is cancelled, so release it now — it must not be
257
+ # re-prompted after the run is over
258
+ self.question.answer = ""
259
+ self.question.event.set()
260
+ self.question = None
261
+ if self._restore is not None:
262
+ # release state the cancelled run borrowed (e.g. a slash
263
+ # command's project dir) now: the worker may finish late
264
+ # (stale) and its own finally must not touch it then
265
+ self._restore()
266
+ self._restore = None
267
+ self.console.print("\n[dim]execution cancelled — add more messages or /exit[/dim]")
268
+ self._flush()
269
+ finally:
270
+ self.agent_running = False
271
+ if not cancelled:
272
+ self.console.print()
273
+ self._flush()
274
+
275
+ def _run_live(self, worker: threading.Thread) -> bool:
276
+ """Live-based display (real terminal). Returns True if cancelled.
277
+
278
+ Event-driven: the render loop blocks on `_data_event` and wakes
279
+ the moment new stream text arrives, so the text pushes the
280
+ scroll immediately instead of waiting for a fixed tick. The
281
+ short timeout keeps the spinner animating between data bursts.
282
+ """
283
+ with Live(
284
+ self._render_frame(),
285
+ console=self.console,
286
+ refresh_per_second=30,
287
+ screen=False,
288
+ ) as live:
289
+ while worker.is_alive():
290
+ if self.question is not None:
291
+ live.stop()
292
+ self._ask_question_blocking()
293
+ live.start()
294
+ continue
295
+ self._data_event.wait(timeout=0.1)
296
+ self._data_event.clear()
297
+ live.update(self._render_frame())
298
+ self._flush()
299
+ live.update(self._render_frame())
300
+ self._flush()
301
+ # run finished: the last Live frame stays on screen, but the
302
+ # in-place redraws never reached the scrollback — print the
303
+ # full conversation so the user can scroll back through it
304
+ self._dump_conversation()
305
+ return False
306
+
307
+ def _run_dumb(self, worker: threading.Thread) -> bool:
308
+ """Dumb-terminal fallback: print each frame as a normal line.
309
+
310
+ rich's Live intentionally renders nothing on dumb terminals
311
+ (TERM unset/"dumb"), so without this the status bar and spinner
312
+ would never appear there. Same event-driven wakeup as `_run_live`.
313
+ """
314
+ self.console.print(self._render_frame())
315
+ self._flush()
316
+ while worker.is_alive():
317
+ if self.question is not None:
318
+ self._ask_question_blocking()
319
+ continue
320
+ self._data_event.wait(timeout=0.1)
321
+ self._data_event.clear()
322
+ self.console.print(self._render_frame())
323
+ self._flush()
324
+ self.console.print(self._render_frame())
325
+ self._flush()
326
+ self._dump_conversation()
327
+ return False
328
+
329
+ def _flush(self) -> None:
330
+ """Force stdout through so Live frames render in real time.
331
+
332
+ rich's Live does not flush after each refresh, and tty stdout is
333
+ line-buffered — without this the status bar/spinner would sit in
334
+ the stdio buffer and only appear once the run ends (or the 8KB
335
+ buffer fills).
336
+ """
337
+ with contextlib.suppress(AttributeError, OSError):
338
+ self.console.file.flush()
339
+
340
+ def _run_agent(
341
+ self,
342
+ text: str,
343
+ seq: int,
344
+ system: str | None = None,
345
+ restore: Callable[[], None] | None = None,
346
+ ) -> None:
347
+ try:
348
+ self.conversation_history.append(Message(role="user", content=text))
349
+ run_agent_loop(
350
+ self.session,
351
+ messages=list(self.conversation_history),
352
+ top_level=True,
353
+ system=system or self.session.system_prompt,
354
+ )
355
+ # Only the current run may update shared state: a stale
356
+ # worker (a newer run started — `run_seq` advanced) must not
357
+ # clobber the next run. A cancelled run with no successor
358
+ # is still current, so it adopts its salvaged partial
359
+ # history and the interrupted turn is not lost (the seq
360
+ # check is the staleness guard; the cancel event no longer
361
+ # blocks the adoption).
362
+ if seq == self.run_seq and self.session.last_messages:
363
+ self.conversation_history = list(self.session.last_messages)
364
+ except Exception as e: # noqa: BLE001
365
+ if seq == self.run_seq:
366
+ self._on_log(f"agent error: {e}")
367
+ finally:
368
+ # Only the current run may touch shared UI state: a stale
369
+ # worker from a cancelled run that finishes late must not
370
+ # wipe the next run's live stream or fire its restore
371
+ # callback (which could reset e.g. a borrowed project dir
372
+ # while the new run is mid-execution). The restore for a
373
+ # cancelled run is released by the Ctrl-C handler instead.
374
+ if seq == self.run_seq:
375
+ # the run is done: the final assistant message is now
376
+ # part of the conversation history, so drop the live
377
+ # stream buffer — otherwise the same text renders twice
378
+ # (stream row + history row) and eats the visible-row
379
+ # budget
380
+ with self.lock:
381
+ self.stream_text = ""
382
+ self._history_dirty = True
383
+ self._data_event.set()
384
+ if restore is not None:
385
+ restore()