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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|