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,412 @@
1
+ """Input handling: SlashCompleter, UiQuestion, key bindings, and the
2
+ InputMixin that provides prompt reading and question blocking.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import threading
9
+ from collections.abc import Callable, Iterable
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from prompt_toolkit import PromptSession
13
+ from prompt_toolkit.completion import Completer, Completion
14
+ from prompt_toolkit.formatted_text import FormattedText
15
+ from prompt_toolkit.history import FileHistory
16
+ from prompt_toolkit.key_binding import KeyBindings
17
+ from prompt_toolkit.patch_stdout import patch_stdout
18
+ from rich.console import Console
19
+ from rich.text import Text
20
+
21
+ from .. import config
22
+
23
+ if TYPE_CHECKING:
24
+ from ..session import Session
25
+
26
+ SLASH_COMMANDS = [
27
+ "/plan",
28
+ "/build",
29
+ "/init",
30
+ "/review",
31
+ "/explain",
32
+ "/compact",
33
+ "/save",
34
+ "/summary",
35
+ "/sessions",
36
+ "/restore",
37
+ "/clear",
38
+ "/model",
39
+ "/exit",
40
+ ]
41
+
42
+
43
+ def _custom_slash_commands() -> list[str]:
44
+ from ..commands import load_custom_commands
45
+
46
+ return sorted(f"/{c.name}" for c in load_custom_commands())
47
+
48
+
49
+ def _history_path() -> str:
50
+ d = config.SESSION_DIR / "python-agent-harness"
51
+ d.mkdir(parents=True, exist_ok=True)
52
+ return str(d / "input_history")
53
+
54
+
55
+ def _make_key_bindings() -> KeyBindings:
56
+ """Esc+Enter (or Alt+Enter) submits; plain Enter inserts a newline.
57
+
58
+ Tab triggers completion explicitly (first Tab inserts the common
59
+ part / opens the menu, further Tabs cycle), Shift+Tab cycles
60
+ backwards — prompt_toolkit's defaults don't reliably bind Tab in
61
+ every mode/version.
62
+ """
63
+ kb = KeyBindings()
64
+
65
+ @kb.add("escape", "enter")
66
+ def _submit(event: Any) -> None:
67
+ event.current_buffer.validate_and_handle()
68
+
69
+ @kb.add("c-i")
70
+ def _complete(event: Any) -> None:
71
+ b = event.current_buffer
72
+ if b.complete_state:
73
+ b.complete_next()
74
+ else:
75
+ b.start_completion(insert_common_part=True)
76
+
77
+ @kb.add("s-tab")
78
+ def _complete_backward(event: Any) -> None:
79
+ b = event.current_buffer
80
+ if b.complete_state:
81
+ b.complete_previous()
82
+ else:
83
+ b.start_completion(select_first=True)
84
+
85
+ return kb
86
+
87
+
88
+ def _make_prompt_session(
89
+ history: FileHistory, completer: Completer, **kwargs: Any
90
+ ) -> PromptSession:
91
+ """Create the TUI's input session.
92
+
93
+ ``complete_while_typing`` is off on purpose: it races with Tab's
94
+ ``start_completion`` (a keystroke-triggered completion can create
95
+ the completion state just before the Tab-triggered task runs, which
96
+ then bails out without inserting the common part). Tab must be the
97
+ single, deterministic trigger.
98
+ """
99
+ return PromptSession(
100
+ history=history,
101
+ key_bindings=_make_key_bindings(),
102
+ completer=completer,
103
+ complete_while_typing=False,
104
+ multiline=True,
105
+ enable_suspend=True,
106
+ **kwargs,
107
+ )
108
+
109
+
110
+ class SlashCompleter(Completer):
111
+ """Tab-completion for the input line.
112
+
113
+ - A first token starting with ``/`` completes against the known
114
+ slash commands (builtins + custom commands from
115
+ prompts/commands/*.md); if no command matches, it is treated as
116
+ an absolute path.
117
+ - After a slash command's space, Tab completes paths relative to
118
+ the session's project dir (absolute and ``~`` paths work too).
119
+ - Any other ``~``-prefixed or ``/``-containing token (e.g.
120
+ ``~/wor``, ``docs/``) completes as a path: ``~`` against $HOME,
121
+ otherwise relative to the project dir. Plain words without ``/``
122
+ are left alone.
123
+ - Directories get a trailing ``/`` so repeated Tab drills deeper;
124
+ ``~`` alone completes to ``~/``.
125
+ """
126
+
127
+ def __init__(self, get_project_dir: Callable[[], str]) -> None:
128
+ self.get_project_dir = get_project_dir
129
+
130
+ def _slash_commands(self) -> list[str]:
131
+ return sorted(set(SLASH_COMMANDS + _custom_slash_commands()))
132
+
133
+ def _complete_paths(self, arg: str) -> Iterable[Completion]:
134
+ expanded = os.path.expanduser(arg)
135
+ if not arg:
136
+ directory, prefix = self.get_project_dir() or os.getcwd(), ""
137
+ elif expanded.endswith(os.sep):
138
+ base = (
139
+ expanded
140
+ if os.path.isabs(expanded)
141
+ else os.path.join(self.get_project_dir() or os.getcwd(), expanded)
142
+ )
143
+ directory, prefix = base, ""
144
+ elif os.path.isdir(expanded):
145
+ # "~" or an existing dir without a trailing slash: complete
146
+ # the trailing slash itself (bash-style), not its siblings.
147
+ yield Completion(text="/", start_position=0, display=arg + "/")
148
+ return
149
+ else:
150
+ base = (
151
+ expanded
152
+ if os.path.isabs(expanded)
153
+ else os.path.join(self.get_project_dir() or os.getcwd(), expanded)
154
+ )
155
+ directory, prefix = os.path.dirname(base), os.path.basename(base)
156
+ try:
157
+ entries = sorted(os.listdir(directory or "."))
158
+ except OSError:
159
+ return
160
+ for name in entries:
161
+ if not name.startswith(prefix):
162
+ continue
163
+ suffix = name[len(prefix) :]
164
+ if os.path.isdir(os.path.join(directory, name)):
165
+ suffix += "/"
166
+ display = name + "/"
167
+ else:
168
+ display = name
169
+ # start_position=0 appends at the cursor; the typed prefix is
170
+ # already in the buffer, so only the remaining suffix is inserted.
171
+ yield Completion(text=suffix, start_position=0, display=display)
172
+
173
+ def get_completions(self, document: Any, complete_event: Any):
174
+ text = document.text_before_cursor
175
+ if text.startswith("/"):
176
+ if " " not in text:
177
+ cmds = [c for c in self._slash_commands() if c.startswith(text)]
178
+ for cmd in cmds:
179
+ yield Completion(cmd, start_position=-len(text))
180
+ if cmds:
181
+ return
182
+ yield from self._complete_paths(text) # absolute path
183
+ return
184
+ arg = text.split(" ", 1)[1]
185
+ yield from self._complete_paths(arg)
186
+ return
187
+ token = text.rsplit(" ", 1)[-1] if " " in text else text
188
+ if token.startswith("~") or "/" in token:
189
+ yield from self._complete_paths(token)
190
+
191
+
192
+ class UiQuestion:
193
+ def __init__(
194
+ self,
195
+ prompt: str,
196
+ multiple: bool = False,
197
+ options: list[str] | None = None,
198
+ custom: bool = True,
199
+ keys: list[str] | None = None,
200
+ ) -> None:
201
+ self.prompt = prompt
202
+ self.multiple = multiple
203
+ self.options = options or []
204
+ self.custom = custom
205
+ # keyed choices (e.g. ["y", "n"] for a confirm): render the
206
+ # options as a keyed list and resolve typed keys to labels,
207
+ # instead of the numbered-list style of the Question tool
208
+ self.keys = keys or []
209
+ self.answer: str | None = None
210
+ self.event = threading.Event()
211
+
212
+
213
+ def _resolve_keyed_choice(answer: str, options: list[str], keys: list[str]) -> str:
214
+ """Map bare keys in ANSWER to the matching option label.
215
+
216
+ Comma-separated keys pick several options (multiple select);
217
+ non-key tokens pass through unchanged as free-text answers.
218
+ """
219
+ if not options or not keys or not answer.strip():
220
+ return answer
221
+ resolved: list[str] = []
222
+ for part in answer.split(","):
223
+ part = part.strip()
224
+ if part.lower() in keys:
225
+ resolved.append(options[keys.index(part.lower())])
226
+ continue
227
+ resolved.append(part)
228
+ return ", ".join(resolved)
229
+
230
+
231
+ def _resolve_numbered_choice(answer: str, options: list[str]) -> str:
232
+ """Map bare numbers in ANSWER (1-based) to the matching option label.
233
+
234
+ Comma-separated numbers pick several options (multiple select);
235
+ non-numeric tokens pass through unchanged as free-text answers;
236
+ out-of-range numbers are kept as typed. Empty answers stay empty.
237
+ """
238
+ if not options or not answer.strip():
239
+ return answer
240
+ resolved: list[str] = []
241
+ for part in answer.split(","):
242
+ part = part.strip()
243
+ if part.isdigit():
244
+ idx = int(part)
245
+ if 1 <= idx <= len(options):
246
+ resolved.append(options[idx - 1])
247
+ continue
248
+ resolved.append(part)
249
+ return ", ".join(resolved)
250
+
251
+
252
+ class InputMixin:
253
+ """Input handling methods for the TUI.
254
+
255
+ Expects the host class to provide: ``session``, ``console``,
256
+ ``question``, ``prompt_session``, ``_data_event``.
257
+ """
258
+
259
+ if TYPE_CHECKING:
260
+ session: Session
261
+ console: Console
262
+ question: UiQuestion | None
263
+ prompt_session: PromptSession
264
+ _data_event: threading.Event
265
+
266
+ def _render_frame(self) -> Any: ...
267
+
268
+ def _flush(self) -> None: ...
269
+
270
+ def _input_prompt(self) -> FormattedText:
271
+ """Styled input prompt: short model name + caret.
272
+
273
+ Shows the model actually in use (the part after the last '/',
274
+ e.g. ``deepseek-ai/deepseek-flash-v4`` → ``deepseek-flash-v4``)
275
+ so the active model stays visible while typing.
276
+ """
277
+ model = self.session.model or ""
278
+ short = model.rsplit("/", 1)[-1] if "/" in model else model
279
+ title = getattr(self.session.store, "title", None)
280
+ if title:
281
+ title = title.strip()
282
+ if len(title) > 20:
283
+ title = title[:20]
284
+ return FormattedText(
285
+ [
286
+ ("bold cyan", f"{short} " if short else ""),
287
+ ("dim", f"({title}) "),
288
+ ("ansibrightblack", "> "),
289
+ ]
290
+ )
291
+ return FormattedText(
292
+ [
293
+ ("bold cyan", f"{short} " if short else ""),
294
+ ("ansibrightblack", "> "),
295
+ ]
296
+ )
297
+
298
+ def _read_multiline(self) -> str | None:
299
+ try:
300
+ with patch_stdout():
301
+ text = self.prompt_session.prompt(self._input_prompt())
302
+ except EOFError:
303
+ # Ctrl-D: quit
304
+ return None
305
+ except KeyboardInterrupt:
306
+ # Ctrl-C: cancel this input, stay in the app
307
+ self.console.print("[dim]input cancelled[/dim]")
308
+ return ""
309
+ return text
310
+
311
+ def _ask_question_blocking(self) -> None:
312
+ q = self.question
313
+ if q is None:
314
+ return
315
+ self.console.print(self._render_frame())
316
+ self.console.print()
317
+ self._flush()
318
+ options = q.options or []
319
+ keys = q.keys or []
320
+ if keys and options and len(keys) == len(options):
321
+ # keyed choices (e.g. y/n confirm): type the key to pick —
322
+ # same list look as the Question tool, keys instead of numbers
323
+ self.console.print(Text(q.prompt))
324
+ for key, opt in zip(keys, options, strict=True):
325
+ line = Text(f" {key}) ", style="cyan")
326
+ line.append(opt)
327
+ self.console.print(line)
328
+ hint = "Enter keys, comma-separated" if q.multiple else "Enter a key"
329
+ if q.custom:
330
+ hint += ", or type your own answer"
331
+ self.console.print(f"[dim]{hint}[/dim]")
332
+ prompt = "> "
333
+ elif options:
334
+ # option labels get a numbered list: type the number to pick
335
+ self.console.print(Text(q.prompt))
336
+ for i, opt in enumerate(options, 1):
337
+ line = Text(f" {i}) ", style="cyan")
338
+ line.append(opt)
339
+ self.console.print(line)
340
+ hint = "Enter numbers, comma-separated" if q.multiple else "Enter a number"
341
+ if q.custom:
342
+ hint += ", or type your own answer"
343
+ self.console.print(f"[dim]{hint}[/dim]")
344
+ prompt = "> "
345
+ else:
346
+ prompt = q.prompt + " > "
347
+ try:
348
+ with patch_stdout():
349
+ answer = self.prompt_session.prompt(prompt, multiline=False)
350
+ except (EOFError, KeyboardInterrupt):
351
+ answer = ""
352
+ if keys:
353
+ q.answer = _resolve_keyed_choice(answer, options, keys)
354
+ else:
355
+ q.answer = _resolve_numbered_choice(answer, options)
356
+ q.event.set()
357
+ self.question = None
358
+ self._data_event.set() # re-render promptly after the answer
359
+
360
+ def _ask_sync(self, q: UiQuestion) -> str:
361
+ """Block the worker thread until the main thread answers.
362
+
363
+ Cancel-aware: the wait polls the session cancel event, so a
364
+ Ctrl-C outside the answer prompt (e.g. during the render loop)
365
+ unblocks the worker immediately instead of wedging it until a
366
+ question is answered. A cancelled run returns an empty answer.
367
+ """
368
+ self.question = q
369
+ cancel = getattr(self.session, "cancel_event", None)
370
+ while not q.event.wait(0.1):
371
+ if cancel is not None and cancel.is_set():
372
+ return ""
373
+ return q.answer or ""
374
+
375
+ def _ui_confirm(self, prompt: str) -> bool:
376
+ """PlanExit confirmation: same look as the Question tool, but a
377
+ y/n keyed choice list instead of numbers (two choices only)."""
378
+ q = UiQuestion(
379
+ prompt,
380
+ options=list(config.PLAN_EXIT_OPTIONS),
381
+ keys=["y", "n"],
382
+ custom=False,
383
+ )
384
+ answer = self._ask_sync(q).strip().lower()
385
+ # resolved answers arrive as the option label; legacy free-text
386
+ # (y/yes/a/1/true) keeps working for muscle memory
387
+ return answer == config.PLAN_EXIT_OPTIONS[0].lower() or answer in (
388
+ "y",
389
+ "yes",
390
+ "a",
391
+ "true",
392
+ "1",
393
+ )
394
+
395
+ def _ui_ask(self, questions: list[dict]) -> str:
396
+ lines = []
397
+ for q in questions:
398
+ prompt = q.get("question", "")
399
+ options = q.get("options") or []
400
+ multiple = bool(q.get("multiple"))
401
+ custom = q.get("custom", True)
402
+ ui_q = UiQuestion(
403
+ prompt,
404
+ multiple=multiple,
405
+ options=list(options),
406
+ custom=custom,
407
+ )
408
+ answer = self._ask_sync(ui_q)
409
+ if multiple:
410
+ answer = ", ".join(a.strip() for a in answer.split(",") if a.strip())
411
+ lines.append(f'"{prompt}" = "{answer}"')
412
+ return "\n".join(lines) if lines else "Unanswered"