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,297 @@
|
|
|
1
|
+
"""Session persistence: auto-save, LLM titles, restore.
|
|
2
|
+
|
|
3
|
+
Ported from gptel-agent-harness-session.el.
|
|
4
|
+
|
|
5
|
+
- Auto-save the conversation after each LLM response to
|
|
6
|
+
~/.local/share/python-agent-harness/sessions/<name>_<YYMMDDHHMMSS>.md
|
|
7
|
+
with a trailing metadata block (;; Local Variables: ...).
|
|
8
|
+
- Async title generation from the first user message (title.md);
|
|
9
|
+
on success the file is renamed to <title>_<TS>.md.
|
|
10
|
+
- restore / restore-latest commands re-load a session.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session_dir() -> Path:
|
|
25
|
+
return config.SESSION_DIR / config.SESSION_SUBDIR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# The roles the save format delimits blocks with (`**<role>**: `).
|
|
29
|
+
SAVED_ROLES = ("user", "assistant", "system", "tool")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def split_role_header(line: str) -> tuple[str, str] | None:
|
|
33
|
+
"""``(role, rest)`` when LINE is a ``**role**: `` block header, else None.
|
|
34
|
+
|
|
35
|
+
The single source of truth for the save format's block delimiter:
|
|
36
|
+
the renderer escapes what this would match and the parser splits on
|
|
37
|
+
exactly what this accepts, so the two can never drift apart.
|
|
38
|
+
"""
|
|
39
|
+
if not line.startswith("**") or "**: " not in line:
|
|
40
|
+
return None
|
|
41
|
+
prefix, _, rest = line.partition("**: ")
|
|
42
|
+
role = prefix.strip("*").strip()
|
|
43
|
+
return (role, rest) if role in SAVED_ROLES else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_escapable(line: str) -> bool:
|
|
47
|
+
"""Whether LINE is a block header, or an already-escaped one."""
|
|
48
|
+
return split_role_header(line.lstrip("\\")) is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def escape_role_headers(body: str) -> str:
|
|
52
|
+
r"""Backslash-escape message-body lines that look like block headers.
|
|
53
|
+
|
|
54
|
+
Blocks are delimited by ``**<role>**: `` at the start of a line, so a
|
|
55
|
+
message whose own text contains such a line — the agent explaining
|
|
56
|
+
this very format, or a pasted transcript — would otherwise be split
|
|
57
|
+
into extra (and misattributed) messages on restore. ``\**user**: ``
|
|
58
|
+
still reads as the literal text in markdown and is reversed by
|
|
59
|
+
`unescape_role_header`; already-escaped lines gain another backslash
|
|
60
|
+
so the round trip is exact at any nesting depth.
|
|
61
|
+
"""
|
|
62
|
+
if "**" not in body:
|
|
63
|
+
return body
|
|
64
|
+
return "\n".join("\\" + ln if _is_escapable(ln) else ln for ln in body.split("\n"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unescape_role_header(line: str) -> str:
|
|
68
|
+
r"""Reverse one level of `escape_role_headers` for a single line.
|
|
69
|
+
|
|
70
|
+
Only lines that would otherwise be read as block headers are
|
|
71
|
+
touched, so a literal ``\**note**: `` in a message survives intact.
|
|
72
|
+
"""
|
|
73
|
+
return line[1:] if line.startswith("\\") and _is_escapable(line) else line
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sanitize_title(title: str) -> str:
|
|
77
|
+
"""Sanitize a generated title (mirrors the elisp semantics)."""
|
|
78
|
+
t = title.strip()
|
|
79
|
+
t = re.sub(r"[\n\r]+", " ", t)
|
|
80
|
+
t = t.strip('"')
|
|
81
|
+
t = re.sub(r"[/\\:*?\"<>|]", "-", t)
|
|
82
|
+
t = re.sub(r"[-_ ]+", "-", t)
|
|
83
|
+
t = t[:50]
|
|
84
|
+
t = re.sub(r"-+$", "", t)
|
|
85
|
+
return t
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def title_from_filename(session_file: str) -> str | None:
|
|
89
|
+
"""Derive a title from a session file name, or None."""
|
|
90
|
+
base = os.path.basename(session_file)
|
|
91
|
+
if base.endswith(".md"):
|
|
92
|
+
base = base[:-3]
|
|
93
|
+
m = re.match(r"(.+)_[0-9]{12}(?:-\d+)?$", base)
|
|
94
|
+
if not m:
|
|
95
|
+
return None
|
|
96
|
+
title = m.group(1).replace("-", " ")
|
|
97
|
+
if " " not in title:
|
|
98
|
+
return None # bare single-word project names rejected
|
|
99
|
+
return title
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionPersistence:
|
|
103
|
+
"""Saves/restores sessions for one agent session."""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
project_dir: str,
|
|
108
|
+
model: str,
|
|
109
|
+
backend: str,
|
|
110
|
+
system_prompt: str | None = None,
|
|
111
|
+
temperature: float | None = None,
|
|
112
|
+
max_tokens: int | None = None,
|
|
113
|
+
tool_names: list[str] | None = None,
|
|
114
|
+
round_times: list[float] | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.project_dir = project_dir
|
|
117
|
+
self.model = model
|
|
118
|
+
self.backend = backend
|
|
119
|
+
self.system_prompt = system_prompt
|
|
120
|
+
self.temperature = temperature
|
|
121
|
+
self.max_tokens = max_tokens
|
|
122
|
+
self.tool_names = tool_names or []
|
|
123
|
+
self.title: str | None = None
|
|
124
|
+
self.file_path: str | None = None
|
|
125
|
+
self.title_pending = False
|
|
126
|
+
self._first_user_msg: str | None = None
|
|
127
|
+
# wall-clock start times of each round, persisted in the
|
|
128
|
+
# metadata block so restored sessions keep their round
|
|
129
|
+
# timestamps (populated by the TUI on each run)
|
|
130
|
+
self.round_times: list[float] = list(round_times) if round_times else []
|
|
131
|
+
# serializes save vs. apply_title: the rename must never
|
|
132
|
+
# interleave with a save's write+replace, or the conversation
|
|
133
|
+
# would split across two files (a titled stale file plus a
|
|
134
|
+
# fresh untitled one) when title generation races a new run's
|
|
135
|
+
# auto-save
|
|
136
|
+
self._io_lock = threading.Lock()
|
|
137
|
+
|
|
138
|
+
# -- file naming ----------------------------------------------------------
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _unique_path(prefix: str) -> str:
|
|
141
|
+
"""A session file path that does not already exist.
|
|
142
|
+
|
|
143
|
+
Same-second collisions (same project name, or two sessions given
|
|
144
|
+
the same title) get a numeric suffix instead of silently
|
|
145
|
+
overwriting an existing session file.
|
|
146
|
+
"""
|
|
147
|
+
path = session_dir() / f"{prefix}.md"
|
|
148
|
+
n = 1
|
|
149
|
+
while path.exists():
|
|
150
|
+
path = session_dir() / f"{prefix}-{n}.md"
|
|
151
|
+
n += 1
|
|
152
|
+
return str(path)
|
|
153
|
+
|
|
154
|
+
def session_file(self) -> str | None:
|
|
155
|
+
if self.file_path:
|
|
156
|
+
return self.file_path
|
|
157
|
+
proj_name = os.path.basename(os.path.normpath(self.project_dir))
|
|
158
|
+
stamp = time.strftime("%y%m%d%H%M%S")
|
|
159
|
+
self.file_path = self._unique_path(f"{proj_name}_{stamp}")
|
|
160
|
+
return self.file_path
|
|
161
|
+
|
|
162
|
+
def remember_first_user_message(self, text: str) -> None:
|
|
163
|
+
if self._first_user_msg is None and len(text.strip()) > 3:
|
|
164
|
+
self._first_user_msg = text.strip()[:500]
|
|
165
|
+
|
|
166
|
+
def first_user_message(self) -> str | None:
|
|
167
|
+
return self._first_user_msg
|
|
168
|
+
|
|
169
|
+
# -- saving ---------------------------------------------------------------
|
|
170
|
+
def metadata_block(self) -> str:
|
|
171
|
+
lines = [";; Local Variables:"]
|
|
172
|
+
pairs = [
|
|
173
|
+
("python-agent-harness--project-dir", self.project_dir),
|
|
174
|
+
("gptel-model", self.model),
|
|
175
|
+
("gptel--backend-name", self.backend),
|
|
176
|
+
("gptel-system-prompt", self.system_prompt),
|
|
177
|
+
("gptel-temperature", self.temperature),
|
|
178
|
+
("gptel-max-tokens", self.max_tokens),
|
|
179
|
+
]
|
|
180
|
+
for name, value in pairs:
|
|
181
|
+
if value is None:
|
|
182
|
+
continue
|
|
183
|
+
lines.append(f";; {name}: {value!r}")
|
|
184
|
+
if self.tool_names:
|
|
185
|
+
names = " ".join(f'"{n}"' for n in self.tool_names)
|
|
186
|
+
lines.append(f";; gptel--tool-names: ({names})")
|
|
187
|
+
if self.round_times:
|
|
188
|
+
stamps = " ".join(repr(float(t)) for t in self.round_times)
|
|
189
|
+
lines.append(f";; python-agent-harness--round-times: {stamps}")
|
|
190
|
+
lines.append(";; End:")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
def save(self, conversation_text: str) -> str | None:
|
|
194
|
+
# under the IO lock so a concurrent apply_title rename cannot
|
|
195
|
+
# land between the tmp write and the os.replace (see _io_lock)
|
|
196
|
+
with self._io_lock:
|
|
197
|
+
path = self.session_file()
|
|
198
|
+
if path is None:
|
|
199
|
+
return None
|
|
200
|
+
session_dir().mkdir(parents=True, exist_ok=True)
|
|
201
|
+
content = conversation_text.rstrip("\n") + "\n\n" + self.metadata_block() + "\n"
|
|
202
|
+
tmp = path + ".tmp"
|
|
203
|
+
Path(tmp).write_text(content, encoding="utf-8")
|
|
204
|
+
os.replace(tmp, path)
|
|
205
|
+
return path
|
|
206
|
+
|
|
207
|
+
def apply_title(self, title: str) -> None:
|
|
208
|
+
"""Rename the session file to <title>_<TS>.md (never overwriting)."""
|
|
209
|
+
# under the IO lock: the rename must not interleave with a
|
|
210
|
+
# concurrent save's write+replace (see _io_lock)
|
|
211
|
+
with self._io_lock:
|
|
212
|
+
title = sanitize_title(title)
|
|
213
|
+
if not title:
|
|
214
|
+
return
|
|
215
|
+
if self.file_path and os.path.exists(self.file_path):
|
|
216
|
+
stamp = time.strftime("%y%m%d%H%M%S")
|
|
217
|
+
new_path = self._unique_path(f"{title}_{stamp}")
|
|
218
|
+
try:
|
|
219
|
+
os.replace(self.file_path, new_path)
|
|
220
|
+
self.file_path = str(new_path)
|
|
221
|
+
except OSError:
|
|
222
|
+
return
|
|
223
|
+
self.title = title
|
|
224
|
+
|
|
225
|
+
# -- restoring ---------------------------------------------------------------
|
|
226
|
+
@staticmethod
|
|
227
|
+
def parse_metadata(text: str) -> dict[str, str]:
|
|
228
|
+
"""Parse the trailing ;; Local Variables: block (search from EOF).
|
|
229
|
+
|
|
230
|
+
Values are stored repr()-style (like the elisp %S printing);
|
|
231
|
+
parsing evaluates them with ast.literal_eval when possible.
|
|
232
|
+
"""
|
|
233
|
+
marker = "\n;; Local Variables:\n"
|
|
234
|
+
idx = text.rfind(marker)
|
|
235
|
+
if idx == -1:
|
|
236
|
+
return {}
|
|
237
|
+
block = text[idx + len(marker) :]
|
|
238
|
+
end = block.find(";; End:")
|
|
239
|
+
if end != -1:
|
|
240
|
+
block = block[:end]
|
|
241
|
+
meta: dict[str, str] = {}
|
|
242
|
+
for line in block.splitlines():
|
|
243
|
+
line = line.strip()
|
|
244
|
+
if not line.startswith(";; "):
|
|
245
|
+
continue
|
|
246
|
+
body = line[3:]
|
|
247
|
+
if ":" not in body:
|
|
248
|
+
continue
|
|
249
|
+
name, _, value = body.partition(":")
|
|
250
|
+
meta[name.strip()] = _parse_metadata_value(value.strip())
|
|
251
|
+
return meta
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def strip_metadata(text: str) -> str:
|
|
255
|
+
marker = "\n;; Local Variables:\n"
|
|
256
|
+
idx = text.rfind(marker)
|
|
257
|
+
if idx == -1:
|
|
258
|
+
return text
|
|
259
|
+
end = text.find(";; End:", idx)
|
|
260
|
+
if end != -1:
|
|
261
|
+
end += len(";; End:")
|
|
262
|
+
return text[:idx] + text[end:]
|
|
263
|
+
return text[:idx]
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def latest_session() -> str | None:
|
|
267
|
+
d = session_dir()
|
|
268
|
+
if not d.is_dir():
|
|
269
|
+
return None
|
|
270
|
+
files = sorted(d.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
271
|
+
return str(files[0]) if files else None
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def list_sessions() -> list[str]:
|
|
275
|
+
d = session_dir()
|
|
276
|
+
if not d.is_dir():
|
|
277
|
+
return []
|
|
278
|
+
return sorted(
|
|
279
|
+
(str(p) for p in d.glob("*.md")),
|
|
280
|
+
key=lambda p: os.path.getmtime(p),
|
|
281
|
+
reverse=True,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_metadata_value(value: str) -> str:
|
|
286
|
+
"""Parse a repr()-style metadata value, mirroring elisp read-from-string."""
|
|
287
|
+
import ast
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
parsed = ast.literal_eval(value)
|
|
291
|
+
if isinstance(parsed, str):
|
|
292
|
+
return parsed
|
|
293
|
+
if isinstance(parsed, (list, tuple)):
|
|
294
|
+
return " ".join(str(x) for x in parsed)
|
|
295
|
+
return str(parsed)
|
|
296
|
+
except (ValueError, SyntaxError):
|
|
297
|
+
return value.strip("\"'")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Build/plan mode management.
|
|
2
|
+
|
|
3
|
+
Ported from gptel-agent-harness.el's build/plan mode section.
|
|
4
|
+
|
|
5
|
+
- Plan mode: read-only except the per-session plan file
|
|
6
|
+
(<tmp>/python-agent-plans-<proj>-<rand>/PLAN.md).
|
|
7
|
+
- Switching to plan truncates the plan file and queues plan.md +
|
|
8
|
+
plan-mode.md (${planInfo} -> plan file path) for injection into the
|
|
9
|
+
next top-level request; switching back queues build-switch.md.
|
|
10
|
+
- Queued prompts are injected before the last user message (appended
|
|
11
|
+
when the last message is a tool result) and consumed exactly once.
|
|
12
|
+
- Sub-agent requests in plan mode get the plan-mode reminder once per
|
|
13
|
+
sub-agent loop.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import random
|
|
20
|
+
import string
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import config
|
|
25
|
+
from .models import AgentMode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _plan_temp_dir() -> str:
|
|
29
|
+
"""Reliable temp dir (first candidate that is set, else /tmp)."""
|
|
30
|
+
for d in (
|
|
31
|
+
os.environ.get("TMPDIR"),
|
|
32
|
+
os.environ.get("TMP"),
|
|
33
|
+
os.environ.get("TEMP"),
|
|
34
|
+
tempfile.gettempdir(),
|
|
35
|
+
):
|
|
36
|
+
if d:
|
|
37
|
+
return os.path.abspath(d)
|
|
38
|
+
return "/tmp"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PlanMode:
|
|
42
|
+
"""Per-session plan-mode state."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, project_dir: str) -> None:
|
|
45
|
+
self.mode = AgentMode.BUILD
|
|
46
|
+
self.project_dir = project_dir
|
|
47
|
+
self.plan_file: str | None = None
|
|
48
|
+
self.pending_prompts: list[str] = []
|
|
49
|
+
|
|
50
|
+
# -- plan file lifecycle --------------------------------------------------
|
|
51
|
+
def plan_temp_dir(self) -> str:
|
|
52
|
+
return _plan_temp_dir()
|
|
53
|
+
|
|
54
|
+
def plan_file_path(self) -> str:
|
|
55
|
+
if self.plan_file:
|
|
56
|
+
return self.plan_file
|
|
57
|
+
proj_name = os.path.basename(os.path.normpath(self.project_dir))
|
|
58
|
+
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
59
|
+
d = os.path.join(self.plan_temp_dir(), f"python-agent-plans-{proj_name}-{suffix}")
|
|
60
|
+
return os.path.join(d, config.PLAN_FILE_NAME)
|
|
61
|
+
|
|
62
|
+
def ensure_plan_file(self) -> str:
|
|
63
|
+
path = self.plan_file_path()
|
|
64
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
65
|
+
if not os.path.exists(path):
|
|
66
|
+
Path(path).write_text("", encoding="utf-8")
|
|
67
|
+
self.plan_file = path
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
def cleanup_plan_file(self) -> None:
|
|
71
|
+
if not self.plan_file:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
if os.path.exists(self.plan_file):
|
|
75
|
+
os.remove(self.plan_file)
|
|
76
|
+
d = os.path.dirname(self.plan_file)
|
|
77
|
+
if os.path.isdir(d) and not os.listdir(d):
|
|
78
|
+
os.rmdir(d)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
self.plan_file = None
|
|
82
|
+
|
|
83
|
+
# -- mode switching ---------------------------------------------------------
|
|
84
|
+
def set_mode(self, mode: AgentMode, prompts: dict[str, str]) -> None:
|
|
85
|
+
"""Set the mode; PROMPTS maps 'plan'/'plan-mode'/'build-switch' to text."""
|
|
86
|
+
if mode == AgentMode.PLAN:
|
|
87
|
+
plan_file = self.ensure_plan_file()
|
|
88
|
+
# start each planning round from an empty file
|
|
89
|
+
if self.plan_file and os.path.exists(plan_file):
|
|
90
|
+
Path(plan_file).write_text("", encoding="utf-8")
|
|
91
|
+
self.mode = mode
|
|
92
|
+
self.plan_file = plan_file
|
|
93
|
+
self.pending_prompts = [
|
|
94
|
+
prompts["plan"],
|
|
95
|
+
prompts["plan-mode"].replace("${planInfo}", plan_file),
|
|
96
|
+
]
|
|
97
|
+
else:
|
|
98
|
+
self.mode = AgentMode.BUILD
|
|
99
|
+
self.pending_prompts = [prompts["build-switch"]]
|
|
100
|
+
|
|
101
|
+
def consume_prompts(self) -> list[str]:
|
|
102
|
+
prompts = self.pending_prompts
|
|
103
|
+
self.pending_prompts = []
|
|
104
|
+
return prompts
|
|
105
|
+
|
|
106
|
+
# -- helpers ----------------------------------------------------------------
|
|
107
|
+
@property
|
|
108
|
+
def is_plan(self) -> bool:
|
|
109
|
+
return self.mode == AgentMode.PLAN
|
|
110
|
+
|
|
111
|
+
def plan_reminder(self) -> str:
|
|
112
|
+
return config.PLAN_MODE_SUBAGENT_REMINDER % (self.plan_file or self.plan_file_path())
|