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,498 @@
1
+ """Prompt loading and assembly.
2
+
3
+ Ported from gptel-agent-harness.el: loads bundled prompt files
4
+ (agent/subagent/commands), strips YAML frontmatter, discovers skills
5
+ for the {{SKILLS}} placeholder, assembles the effective system prompt
6
+ from project context files + task-completion rules + agent prompt, and
7
+ provides the compaction flow helpers (summarize the conversation with
8
+ the compact prompt and rebuild the history with every user prompt
9
+ preserved verbatim), shared by the in-loop compaction and the manual
10
+ /compact command.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import subprocess
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import config
23
+ from .models import Message
24
+
25
+
26
+ def read_prompt_file(name: str) -> str:
27
+ """Read a prompt file from the package prompts dir."""
28
+ path = Path(__file__).parent / "prompts" / name
29
+ return path.read_text(encoding="utf-8")
30
+
31
+
32
+ _FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n?", re.DOTALL)
33
+ _SKILLS_PLACEHOLDER_RE = re.compile(r"\{\{\s*SKILLS\s*\}\}")
34
+ _SKILLS_FALLBACK = (
35
+ "Invoke with a skill name and optional args; the tool reports an "
36
+ "error if no matching skill is found."
37
+ )
38
+
39
+
40
+ def strip_frontmatter(text: str) -> str:
41
+ """Strip a leading YAML frontmatter block (--- ... ---) if present."""
42
+ return _FRONTMATTER_RE.sub("", text, count=1)
43
+
44
+
45
+ def _parse_skill_frontmatter(skill_file: Path) -> tuple[str, str] | None:
46
+ """Extract (name, description) from a SKILL.md frontmatter block."""
47
+ try:
48
+ text = skill_file.read_text(encoding="utf-8", errors="replace")
49
+ except OSError:
50
+ return None
51
+ m = re.match(r"\A---\n(.*?\n)---\n?", text, re.DOTALL)
52
+ if not m:
53
+ return None
54
+ name = desc = ""
55
+ for line in m.group(1).splitlines():
56
+ if line.startswith("name:"):
57
+ name = line[len("name:") :].strip()
58
+ elif line.startswith("description:"):
59
+ desc = line[len("description:") :].strip()
60
+ if name:
61
+ return (name, desc)
62
+ return None
63
+
64
+
65
+ def index_skills(skill_dir: Path | str | None) -> dict[str, tuple[str, str]]:
66
+ """Index skills by their frontmatter ``name``.
67
+
68
+ Mirrors opencode's skill service: recursively scan *skill_dir* for
69
+ ``SKILL.md`` files (following symlinks), parse each file's YAML
70
+ frontmatter, and return a mapping of frontmatter ``name`` to
71
+ ``(path, description)``. Files without a frontmatter ``name`` are
72
+ skipped, so the advertised listing and the lookup index always agree
73
+ on the same names. Duplicate names keep the last file in
74
+ sorted-path order (deterministic, like opencode's overwrite).
75
+ """
76
+ if not skill_dir:
77
+ return {}
78
+ root = Path(skill_dir)
79
+ if not root.is_dir():
80
+ return {}
81
+ skills: dict[str, tuple[str, str]] = {}
82
+ visited: set[str] = set()
83
+ found: list[tuple[str, str, str]] = []
84
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
85
+ real = os.path.realpath(dirpath)
86
+ if real in visited:
87
+ dirnames[:] = []
88
+ continue
89
+ visited.add(real)
90
+ dirnames[:] = [
91
+ d for d in dirnames if os.path.realpath(os.path.join(dirpath, d)) not in visited
92
+ ]
93
+ if "SKILL.md" not in filenames:
94
+ continue
95
+ path = os.path.realpath(os.path.join(dirpath, "SKILL.md"))
96
+ parsed = _parse_skill_frontmatter(Path(path))
97
+ if parsed:
98
+ name, desc = parsed
99
+ found.append((path, name, desc))
100
+ # os.walk yields directories in arbitrary order, so resolve duplicate
101
+ # names by path explicitly: sort first, then insert, and the last file
102
+ # in sorted-path order wins (deterministic, like opencode's overwrite).
103
+ for path, name, desc in sorted(found):
104
+ skills[name] = (path, desc)
105
+ return dict(sorted(skills.items()))
106
+
107
+
108
+ def discover_skills(skill_dir: Path | str | None) -> str:
109
+ """Build a skill listing from a skill directory.
110
+
111
+ Recursively indexes ``SKILL.md`` files by their frontmatter
112
+ name/description (same index the Skill tool resolves against).
113
+ Returns a formatted listing string, or the static fallback if no
114
+ skills are found.
115
+ """
116
+ skills = index_skills(skill_dir)
117
+ if not skills:
118
+ return _SKILLS_FALLBACK
119
+ lines = ["<available-skills>"]
120
+ for name, (_, desc) in skills.items():
121
+ lines.append(" <skill>")
122
+ lines.append(f" <name>{name}</name>")
123
+ lines.append(f" <description>{desc}</description>")
124
+ lines.append(" </skill>")
125
+ lines.append("</available-skills>")
126
+ return "\n".join(lines)
127
+
128
+
129
+ def load_agent_prompt(path: Path | str | None, skill_dir: Path | str | None = None) -> str | None:
130
+ """Load an opencode-style agent prompt file, or None if unavailable.
131
+
132
+ Strips the YAML frontmatter header (name/description/tools) since
133
+ that metadata isn't part of the prompt text, and substitutes the
134
+ ``{{SKILLS}}`` placeholder with the discovered skill listing from
135
+ *skill_dir* (or a static fallback if no skills are found).
136
+
137
+ Missing files, unreadable files, and empty files all resolve to None
138
+ so callers can fall back cleanly to no system prompt.
139
+ """
140
+ if not path:
141
+ return None
142
+ p = Path(path)
143
+ try:
144
+ text = p.read_text(encoding="utf-8")
145
+ except OSError:
146
+ return None
147
+ text = strip_frontmatter(text)
148
+ skills_text = discover_skills(skill_dir)
149
+ text = _SKILLS_PLACEHOLDER_RE.sub(skills_text, text)
150
+ text = text.strip()
151
+ return text or None
152
+
153
+
154
+ def _git_toplevel(directory: str) -> str:
155
+ """Return the git worktree root for *directory*.
156
+
157
+ Mirrors opencode's git.repo.discover: walk up for ``.git``, then
158
+ run ``git rev-parse --show-toplevel``. Falls back to the nearest
159
+ ``.git`` parent when git itself is unusable, and to *directory*
160
+ when no repository is found (so AGENTS.md lookup still works in
161
+ non-git projects, bounded by the project directory).
162
+ """
163
+ d = Path(directory).resolve()
164
+ for parent in [d, *d.parents]:
165
+ if (parent / ".git").exists():
166
+ try:
167
+ proc = subprocess.run(
168
+ ["git", "rev-parse", "--show-toplevel"],
169
+ cwd=str(parent),
170
+ capture_output=True,
171
+ text=True,
172
+ timeout=10,
173
+ )
174
+ if proc.returncode == 0 and proc.stdout.strip():
175
+ return proc.stdout.strip()
176
+ except (OSError, subprocess.SubprocessError):
177
+ pass
178
+ return str(parent)
179
+ return str(d)
180
+
181
+
182
+ def _find_up(filename: str, start: Path, stop: Path) -> list[str]:
183
+ """Every *filename* from *start* up to and including *stop*.
184
+
185
+ Mirrors opencode's ``FileSystem.findUp``: collect EVERY match along
186
+ the way, not just the nearest one, and stop after *stop* (or at the
187
+ filesystem root, whichever comes first).
188
+ """
189
+ matches: list[str] = []
190
+ current = start
191
+ while True:
192
+ candidate = current / filename
193
+ if candidate.is_file():
194
+ matches.append(str(candidate))
195
+ if current == stop:
196
+ break
197
+ parent = current.parent
198
+ if parent == current:
199
+ break
200
+ current = parent
201
+ return matches
202
+
203
+
204
+ def find_agents_md_files(project_dir: str) -> list[str]:
205
+ """Locate the project's ``AGENTS.md`` files, nearest first.
206
+
207
+ Collects EVERY ``AGENTS.md`` walking up from *project_dir* to the
208
+ git worktree root (mirrors opencode's ``Instruction.systemPaths``),
209
+ so running the agent in a subdirectory still picks up the repo-root
210
+ instructions.
211
+
212
+ ``AGENTS.md`` is the ONLY recognized file: there is no user-global
213
+ instruction file and no ``CLAUDE.md``/``CONTEXT.md`` fallback, so a
214
+ project without an ``AGENTS.md`` gets nothing injected. The walk
215
+ only ever goes upward, bounded by the git worktree root (or
216
+ *project_dir* outside a repo), so it neither escapes into unrelated
217
+ parent directories nor discovers files in subdirectories.
218
+ """
219
+ start = Path(project_dir).resolve()
220
+ stop = Path(_git_toplevel(project_dir)).resolve()
221
+ if not start.is_relative_to(stop):
222
+ # git reported a worktree root that is not an ancestor of the
223
+ # resolved project dir (differently-spelled paths — symlinked or
224
+ # automounted checkouts). Without this guard _find_up would walk
225
+ # to the filesystem root looking for `stop` and pick up AGENTS.md
226
+ # files from unrelated ancestors.
227
+ stop = start
228
+ return _find_up("AGENTS.md", start, stop)
229
+
230
+
231
+ def load_context_files(
232
+ context_dir: Path | str | None,
233
+ extra_files: list[str] | None = None,
234
+ ) -> str | None:
235
+ """Format *extra_files* plus every file in *context_dir* as context.
236
+
237
+ Returns a string like:
238
+ Request context:
239
+
240
+ In file `/path/to/project/AGENTS.md`:
241
+
242
+ <file contents>
243
+
244
+ In file `/path/to/project/contexts/README.md`:
245
+
246
+ <file contents>
247
+
248
+ *extra_files* are individual files outside the context directory
249
+ (the project's ``AGENTS.md`` files) and come first, in the order
250
+ given; the context directory's own files follow, sorted by name.
251
+ They are read and rendered identically: an ``In file `path`:``
252
+ header is the only delimiter, and contents are NOT wrapped in a code
253
+ fence (a fence would be closed early by any file containing one).
254
+
255
+ Unreadable and empty files are skipped, and a file reachable both
256
+ ways (a *context_dir* that also holds a discovered ``AGENTS.md``) is
257
+ rendered once. Returns None when there is nothing to inject.
258
+ """
259
+ paths: list[Path] = []
260
+ seen: set[Path] = set()
261
+
262
+ def _add(path: Path) -> None:
263
+ resolved = path.resolve()
264
+ if resolved in seen:
265
+ return
266
+ seen.add(resolved)
267
+ paths.append(path)
268
+
269
+ for extra in extra_files or []:
270
+ _add(Path(extra))
271
+ d = Path(context_dir) if context_dir else None
272
+ if d and d.is_dir():
273
+ for child in sorted(d.iterdir()):
274
+ if child.is_file():
275
+ _add(child)
276
+ blocks: list[str] = []
277
+ for path in paths:
278
+ try:
279
+ content = path.read_text(encoding="utf-8", errors="replace")
280
+ except OSError:
281
+ continue
282
+ if not content.strip():
283
+ continue
284
+ blocks.append(f"In file `{path}`:\n\n{content.rstrip()}")
285
+ if not blocks:
286
+ return None
287
+ return "Request context:\n\n" + "\n\n".join(blocks)
288
+
289
+
290
+ def load_task_completion_rules() -> str | None:
291
+ """Load ``prompts/task-completion-rules.md``, or None if unavailable.
292
+
293
+ These rules are injected automatically into the main agent and
294
+ session-command system prompts, so the model never stops before the
295
+ task is fully completed and verified. Sub-agents are intentionally
296
+ excluded: they get ONLY their own prompt (subagent.md) with no
297
+ extra context injected.
298
+ """
299
+ p = Path(__file__).parent / "prompts" / "task-completion-rules.md"
300
+ try:
301
+ text = p.read_text(encoding="utf-8")
302
+ except OSError:
303
+ return None
304
+ text = text.strip()
305
+ return text or None
306
+
307
+
308
+ def assemble_agent_prompt(
309
+ project_dir: str,
310
+ agent_prompt: str | None,
311
+ include_context: bool = True,
312
+ context_path: str | None = None,
313
+ ) -> str | None:
314
+ """Assemble the effective system prompt for an agent run.
315
+
316
+ Order: [project context files, AGENTS.md first] ->
317
+ task-completion-rules.md -> the actual agent prompt. The completion
318
+ rules are always the LAST context piece, immediately before the
319
+ agent prompt, so they read as global ground rules rather than part
320
+ of the task instructions.
321
+
322
+ ``include_context=False`` drops the context section (rules are still
323
+ included). This function is NOT used for sub-agents: their system
324
+ prompt is their own prompt file (subagent.md) only, with no context
325
+ files and no task-completion rules (see ``cli.make_session`` and
326
+ ``subagent._subagent_system_prompt``). ``context_path`` overrides
327
+ the default context directory discovery.
328
+ Returns None if every part is empty/missing.
329
+ """
330
+ parts: list[str] = []
331
+ if include_context:
332
+ # lazy import: harness imports this module at call time
333
+ from .session import find_context_dir
334
+
335
+ # the project's AGENTS.md files are just context files that live
336
+ # outside the context directory — same block format, same section
337
+ context_block = load_context_files(
338
+ find_context_dir(project_dir, context_path),
339
+ extra_files=find_agents_md_files(project_dir),
340
+ )
341
+ if context_block:
342
+ parts.append(context_block)
343
+ rules = load_task_completion_rules()
344
+ if rules:
345
+ parts.append(rules)
346
+ if agent_prompt:
347
+ parts.append(agent_prompt)
348
+ return "\n\n".join(parts) if parts else None
349
+
350
+
351
+ def _message_text(msg: object) -> str:
352
+ """Plain text of a Message object or an OpenAI-style dict."""
353
+ text = getattr(msg, "text", None)
354
+ if callable(text):
355
+ result = text()
356
+ if isinstance(result, str):
357
+ return result
358
+ if not isinstance(msg, dict):
359
+ return ""
360
+ content = msg.get("content")
361
+ if isinstance(content, str):
362
+ return content
363
+ if isinstance(content, list):
364
+ return "".join(
365
+ p.get("text", "")
366
+ for p in content
367
+ if isinstance(p, dict) and isinstance(p.get("text"), str)
368
+ )
369
+ return ""
370
+
371
+
372
+ def _message_role(msg: object) -> str | None:
373
+ """Role of a Message object or an OpenAI-style dict."""
374
+ if isinstance(msg, dict):
375
+ return msg.get("role")
376
+ return getattr(msg, "role", None)
377
+
378
+
379
+ def _is_plan_exit_notice(text: str) -> bool:
380
+ """True for the plan-exit approval notice (PLAN_EXIT_APPROVED_MESSAGE
381
+ with the plan file path substituted in)."""
382
+ template = config.PLAN_EXIT_APPROVED_MESSAGE
383
+ if "%s" not in template:
384
+ return text == template
385
+ prefix, suffix = template.split("%s", 1)
386
+ return text.startswith(prefix) and text.endswith(suffix)
387
+
388
+
389
+ def _is_mode_reminder_text(text: str) -> bool:
390
+ """True for harness-injected plan/build mode reminders.
391
+
392
+ Content-based: the plan.md / plan-mode.md / build-switch.md prompts
393
+ all start with the <system-reminder> tag, and the plan-exit approval
394
+ notice has a fixed format — the same checks the TUI uses, which also
395
+ cover restored sessions where the ``injected`` flag is lost.
396
+ """
397
+ if text.startswith("<system-reminder>"):
398
+ return True
399
+ return _is_plan_exit_notice(text)
400
+
401
+
402
+ def user_prompt_texts(messages: list) -> list[str]:
403
+ """Every user prompt in *messages*, oldest first.
404
+
405
+ Used by the compaction flows to rebuild the conversation after a
406
+ summary: the compacted frame is followed by every prompt, so the
407
+ actual requests survive compaction.
408
+
409
+ Accepts Message objects or OpenAI-style dicts. Excludes:
410
+ - nudges (the completion-supervision message — never user input)
411
+ - previously compacted summary frames: harness artifacts that the
412
+ new summary supersedes (the content check also covers restored
413
+ sessions, where the ``injected`` flag is lost)
414
+
415
+ Plan/build-mode reminders are KEPT, but only the most recent batch:
416
+ they carry the current mode context (read-only plan phase, the plan
417
+ file path, "execute the approved plan"), so dropping them at
418
+ compaction would leave the model unsure whether it is planning or
419
+ building — yet replaying every historical reminder would feed it
420
+ stale, possibly contradictory mode instructions after a /plan ->
421
+ /build switch. Each mode switch injects its reminders as one
422
+ contiguous batch, so the last batch IS the current mode state.
423
+ (``remember_user_text`` excludes reminders for title generation —
424
+ a different purpose, so the filters differ.)
425
+ """
426
+ nudge = config.NUDGE_MESSAGE
427
+ is_reminder: list[bool] = []
428
+ last_reminder = -1
429
+ for msg in messages:
430
+ if _message_role(msg) != "user":
431
+ is_reminder.append(False)
432
+ continue
433
+ text = _message_text(msg)
434
+ rem = bool(text) and _is_mode_reminder_text(text)
435
+ is_reminder.append(rem)
436
+ if rem:
437
+ last_reminder = len(is_reminder) - 1
438
+ if last_reminder >= 0:
439
+ batch_start = last_reminder
440
+ while batch_start > 0 and is_reminder[batch_start - 1]:
441
+ batch_start -= 1
442
+ else:
443
+ batch_start = -1
444
+ prompts: list[str] = []
445
+ for i, msg in enumerate(messages):
446
+ if _message_role(msg) != "user":
447
+ continue
448
+ text = _message_text(msg)
449
+ if not text or text == nudge:
450
+ continue
451
+ if text.startswith(config.COMPACT_HEADER):
452
+ continue
453
+ if is_reminder[i] and not (batch_start <= i <= last_reminder):
454
+ continue
455
+ prompts.append(text)
456
+ return prompts
457
+
458
+
459
+ def compact_summary(
460
+ client: Any,
461
+ conversation: str,
462
+ cancel_check: Callable[[], bool] | None = None,
463
+ ) -> str | None:
464
+ """Ask the model to summarize *conversation* using the compact prompt.
465
+
466
+ Shared by the in-loop compaction (``AgentLoop.compact``) and the
467
+ manual /compact command (``Session.compact_conversation``).
468
+ Returns the summary text with the reasoning preamble stripped, or
469
+ None when the response carries no text. Client exceptions
470
+ propagate to the caller, which owns the failure handling
471
+ (log/notify/status message).
472
+ """
473
+ system = read_prompt_file("compact.md")
474
+ kwargs: dict[str, Any] = {}
475
+ if cancel_check is not None:
476
+ kwargs["cancel_check"] = cancel_check
477
+ resp, _ = client.chat_sync(
478
+ [Message(role="user", content=conversation)],
479
+ system=system,
480
+ **kwargs,
481
+ )
482
+ summary = resp.text_without_reasoning()
483
+ return summary or None
484
+
485
+
486
+ def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
487
+ """The post-compaction history: the summary frame as a user message
488
+ followed by every preserved user prompt, oldest first.
489
+
490
+ The summary lives in the user turn (the system prompt is passed
491
+ separately and stays untouched); *prompts* are the real user
492
+ requests (see ``user_prompt_texts``) that must survive compaction.
493
+ """
494
+ frame = (config.COMPACT_HEADER + summary + config.COMPACT_SEPARATOR).strip()
495
+ return [
496
+ Message(role="user", content=frame),
497
+ *[Message(role="user", content=p) for p in prompts],
498
+ ]