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,652 @@
|
|
|
1
|
+
"""Slash command handling for the TUI.
|
|
2
|
+
|
|
3
|
+
Contains the CommandMixin with all slash command dispatch, session
|
|
4
|
+
command execution (/init /review /explain), /model switching,
|
|
5
|
+
/compact, /summary, /sessions, /restore, and session body parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import threading
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit import PromptSession
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
|
|
19
|
+
from .. import config
|
|
20
|
+
from ..commands import find_command
|
|
21
|
+
from ..models import Message
|
|
22
|
+
from ..persistence import (
|
|
23
|
+
SessionPersistence,
|
|
24
|
+
escape_role_headers,
|
|
25
|
+
split_role_header,
|
|
26
|
+
title_from_filename,
|
|
27
|
+
unescape_role_header,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
|
|
33
|
+
from ..session import Session
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CommandMixin:
|
|
37
|
+
"""Slash command methods for the TUI.
|
|
38
|
+
|
|
39
|
+
Expects the host class to provide: ``session``, ``console``,
|
|
40
|
+
``conversation_history``, ``_history_dirty``, ``_data_event``,
|
|
41
|
+
``agent_running``, ``status``, ``_current_tool``, ``_start_agent``,
|
|
42
|
+
``_status_bar``, ``_flush``, ``_render_frame``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
session: Session
|
|
47
|
+
console: Console
|
|
48
|
+
prompt_session: PromptSession
|
|
49
|
+
conversation_history: list[Message]
|
|
50
|
+
_history_dirty: bool
|
|
51
|
+
_data_event: threading.Event
|
|
52
|
+
agent_running: bool
|
|
53
|
+
status: str
|
|
54
|
+
_current_tool: str
|
|
55
|
+
_round_times: list[float]
|
|
56
|
+
|
|
57
|
+
def _start_agent(
|
|
58
|
+
self,
|
|
59
|
+
text: str,
|
|
60
|
+
system: str | None = None,
|
|
61
|
+
restore: Callable[[], None] | None = None,
|
|
62
|
+
) -> None: ...
|
|
63
|
+
|
|
64
|
+
def _status_bar(self) -> Any: ...
|
|
65
|
+
|
|
66
|
+
def _flush(self) -> None: ...
|
|
67
|
+
|
|
68
|
+
def _render_frame(self) -> Any: ...
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# slash commands
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
def _handle_slash(self, line: str) -> bool:
|
|
74
|
+
cmd, _, arg = line.partition(" ")
|
|
75
|
+
cmd = cmd.strip().lower()
|
|
76
|
+
arg = arg.strip()
|
|
77
|
+
if cmd == "/exit":
|
|
78
|
+
return True
|
|
79
|
+
if cmd == "/plan":
|
|
80
|
+
self.session.switch_to_plan()
|
|
81
|
+
self.console.print(
|
|
82
|
+
"[yellow]Plan mode — read-only; only the plan file is writable.[/yellow]"
|
|
83
|
+
)
|
|
84
|
+
elif cmd == "/build":
|
|
85
|
+
self.session.switch_to_build()
|
|
86
|
+
self.console.print("[green]Build mode.[/green]")
|
|
87
|
+
elif cmd == "/compact":
|
|
88
|
+
self._run_compact()
|
|
89
|
+
elif cmd == "/save":
|
|
90
|
+
path = self.session.store.save(self._conversation_text())
|
|
91
|
+
self.console.print(f"saved: {path}")
|
|
92
|
+
elif cmd == "/summary":
|
|
93
|
+
self._run_summary()
|
|
94
|
+
elif cmd in ("/init", "/review", "/explain"):
|
|
95
|
+
self._run_slash_command(cmd[1:], arg)
|
|
96
|
+
elif cmd == "/sessions":
|
|
97
|
+
self._run_sessions()
|
|
98
|
+
elif cmd == "/restore":
|
|
99
|
+
self._run_restore(arg)
|
|
100
|
+
elif cmd == "/clear":
|
|
101
|
+
# Replacing the conversation is a new generation: invalidate any
|
|
102
|
+
# worker still winding down from a cancelled run, or its
|
|
103
|
+
# salvaged-history commit would resurrect what we just wiped.
|
|
104
|
+
self.session.run_generation += 1
|
|
105
|
+
self.conversation_history = []
|
|
106
|
+
self.session.last_messages = []
|
|
107
|
+
self.session.clear_todos()
|
|
108
|
+
self.console.print("[yellow]Conversation history cleared.[/yellow]")
|
|
109
|
+
elif cmd == "/model":
|
|
110
|
+
self._run_model_command(arg)
|
|
111
|
+
elif cmd == "/help":
|
|
112
|
+
self.console.print(
|
|
113
|
+
"/plan /build /init /review /explain /compact "
|
|
114
|
+
"/save /summary /sessions /restore /clear /model /exit\n"
|
|
115
|
+
"/init [project] [--extra TEXT] create/update AGENTS.md\n"
|
|
116
|
+
"/review [project] [commit|branch|PR] review code changes\n"
|
|
117
|
+
"/explain [project] [target] explain code\n"
|
|
118
|
+
"/sessions list saved sessions\n"
|
|
119
|
+
"/restore [path | title | --latest | latest] restore a saved session\n"
|
|
120
|
+
"/model [name] switch LLM model profile\n"
|
|
121
|
+
"Ctrl-C cancels the current execution (app stays open); "
|
|
122
|
+
"Ctrl-D or /exit quits.",
|
|
123
|
+
markup=False,
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
self.console.print(f"unknown command: {cmd}")
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# command slash commands (/init /review /explain)
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _split_args(arg: str) -> list[str]:
|
|
134
|
+
"""Split a slash-command argument string (shell-like quoting)."""
|
|
135
|
+
try:
|
|
136
|
+
return shlex.split(arg)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return arg.split()
|
|
139
|
+
|
|
140
|
+
def _command_args(self, name: str, arg: str) -> tuple[str | None, str | None]:
|
|
141
|
+
"""Parse slash-command args into (project, extra).
|
|
142
|
+
|
|
143
|
+
Positional order matches the CLI: [project] then the command's
|
|
144
|
+
argument (commit/branch/PR for review, target for explain,
|
|
145
|
+
--extra TEXT for init). A lone first token that isn't an
|
|
146
|
+
existing directory is treated as the command's argument instead
|
|
147
|
+
of a project, so `/review main` reviews the branch `main` of the
|
|
148
|
+
current project and `/explain client.py` explains `client.py`.
|
|
149
|
+
"""
|
|
150
|
+
parts = self._split_args(arg)
|
|
151
|
+
if not parts:
|
|
152
|
+
return None, None
|
|
153
|
+
if name == "init":
|
|
154
|
+
project = None
|
|
155
|
+
extra = None
|
|
156
|
+
rest = parts
|
|
157
|
+
if rest and rest[0] != "--extra":
|
|
158
|
+
project = rest[0]
|
|
159
|
+
rest = rest[1:]
|
|
160
|
+
if rest:
|
|
161
|
+
if rest[0] != "--extra":
|
|
162
|
+
return None, None
|
|
163
|
+
extra = " ".join(rest[1:]) if len(rest) > 1 else None
|
|
164
|
+
return project, extra
|
|
165
|
+
first_is_dir = os.path.isdir(os.path.abspath(os.path.expanduser(parts[0])))
|
|
166
|
+
if first_is_dir:
|
|
167
|
+
return parts[0], " ".join(parts[1:]) or None
|
|
168
|
+
return None, " ".join(parts)
|
|
169
|
+
|
|
170
|
+
def _run_slash_command(self, name: str, arg: str) -> None:
|
|
171
|
+
"""Run a SessionCommand (/init /review /explain) in this session.
|
|
172
|
+
|
|
173
|
+
The command's prompt replaces the system prompt for this run
|
|
174
|
+
only; the output streams into the conversation panel and stays
|
|
175
|
+
in history, so the user can follow up on the result. When a
|
|
176
|
+
different project is given, the session's project dir is
|
|
177
|
+
borrowed for the run (tool cwd) and restored afterwards.
|
|
178
|
+
"""
|
|
179
|
+
cmd = find_command(name)
|
|
180
|
+
if cmd is None:
|
|
181
|
+
self.console.print(f"[yellow]unknown command: /{name}[/yellow]")
|
|
182
|
+
return
|
|
183
|
+
project, extra = self._command_args(name, arg)
|
|
184
|
+
if name == "explain" and project is None and not extra:
|
|
185
|
+
self.console.print(
|
|
186
|
+
"[yellow]/explain needs a target — e.g. /explain client.py "
|
|
187
|
+
"or /explain the retry logic in client.py[/yellow]"
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
if project:
|
|
191
|
+
project = os.path.abspath(os.path.expanduser(project))
|
|
192
|
+
cwd, prompt, kickoff = cmd.prepare(
|
|
193
|
+
project_dir=project or self.session.project_dir, extra=extra
|
|
194
|
+
)
|
|
195
|
+
if self.conversation_history or self.session.last_messages:
|
|
196
|
+
# The commands' kickoffs ("Proceed with the task described
|
|
197
|
+
# in your instructions.") assume a fresh conversation: the
|
|
198
|
+
# task lives only in the system prompt. Mid-conversation
|
|
199
|
+
# that reads as a continuation of the previous — already
|
|
200
|
+
# finished — task, so the model keeps working on the old
|
|
201
|
+
# one instead of the command's. Anchor the new task by
|
|
202
|
+
# naming the command (and target) and marking the earlier
|
|
203
|
+
# conversation as background context only.
|
|
204
|
+
target = f": {extra}" if extra else ""
|
|
205
|
+
kickoff = (
|
|
206
|
+
f"{kickoff.strip()}\n\n"
|
|
207
|
+
f"This is a NEW /{name} request{target} — the messages "
|
|
208
|
+
"above are background context from an earlier task; "
|
|
209
|
+
"follow the NEW instructions in your system prompt."
|
|
210
|
+
)
|
|
211
|
+
# keep the project context + task-completion rules in front of
|
|
212
|
+
# the command's prompt (the "actual agent prompt" for this run)
|
|
213
|
+
from ..prompts import assemble_agent_prompt
|
|
214
|
+
|
|
215
|
+
system = assemble_agent_prompt(
|
|
216
|
+
cwd, prompt, context_path=self.session._configured_context_path
|
|
217
|
+
)
|
|
218
|
+
prev_project = self.session.project_dir
|
|
219
|
+
if cwd != prev_project:
|
|
220
|
+
self.session.project_dir = cwd
|
|
221
|
+
|
|
222
|
+
def _restore() -> None:
|
|
223
|
+
# idempotent: only undo OUR borrow, never clobber a
|
|
224
|
+
# newer run's borrow (or a restore already performed)
|
|
225
|
+
if self.session.project_dir == cwd:
|
|
226
|
+
self.session.project_dir = prev_project
|
|
227
|
+
|
|
228
|
+
restore = _restore
|
|
229
|
+
else:
|
|
230
|
+
restore = None
|
|
231
|
+
if not cmd.allow_planexit:
|
|
232
|
+
# init/review: all tools except PlanExit — hide it for the
|
|
233
|
+
# run (sub-agents share the session registry, so they are
|
|
234
|
+
# covered too) and put it back when the run finishes.
|
|
235
|
+
from ..commands import hide_planexit
|
|
236
|
+
|
|
237
|
+
planexit_restore = hide_planexit(self.session)
|
|
238
|
+
if planexit_restore is not None:
|
|
239
|
+
prev_restore = restore
|
|
240
|
+
state = {"done": False}
|
|
241
|
+
|
|
242
|
+
def _restore() -> None:
|
|
243
|
+
if state["done"]:
|
|
244
|
+
return
|
|
245
|
+
state["done"] = True
|
|
246
|
+
if prev_restore is not None:
|
|
247
|
+
prev_restore()
|
|
248
|
+
planexit_restore()
|
|
249
|
+
|
|
250
|
+
restore = _restore
|
|
251
|
+
self.console.print(f"[cyan]/{name}: {kickoff.strip()}[/cyan]")
|
|
252
|
+
self._start_agent(kickoff, system=system, restore=restore)
|
|
253
|
+
|
|
254
|
+
def _conversation_text(self) -> str:
|
|
255
|
+
msgs = self.session.last_messages or []
|
|
256
|
+
parts = []
|
|
257
|
+
for m in msgs:
|
|
258
|
+
# escaped: see persistence.escape_role_headers
|
|
259
|
+
body = escape_role_headers(m.text())
|
|
260
|
+
if body:
|
|
261
|
+
parts.append(f"**{m.role}**: {body}")
|
|
262
|
+
return "\n\n".join(parts)
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# direct commands (no LLM agent loop)
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
def _run_with_status(
|
|
268
|
+
self,
|
|
269
|
+
worker: Callable[[], None],
|
|
270
|
+
*,
|
|
271
|
+
status_text: str,
|
|
272
|
+
cancel_message: str,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Run *worker* in a background thread with status bar updates.
|
|
275
|
+
|
|
276
|
+
Sets the status bar to *status_text*, renders the spinner while
|
|
277
|
+
the worker is in flight, and handles KeyboardInterrupt with
|
|
278
|
+
*cancel_message*. Used by /compact and /summary to avoid
|
|
279
|
+
duplicating the thread + Live boilerplate.
|
|
280
|
+
"""
|
|
281
|
+
self.status = status_text
|
|
282
|
+
self._current_tool = ""
|
|
283
|
+
self.agent_running = True
|
|
284
|
+
self._data_event.clear()
|
|
285
|
+
|
|
286
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
287
|
+
thread.start()
|
|
288
|
+
try:
|
|
289
|
+
if self.console.is_dumb_terminal:
|
|
290
|
+
while thread.is_alive():
|
|
291
|
+
self._data_event.wait(timeout=0.1)
|
|
292
|
+
self._data_event.clear()
|
|
293
|
+
self.console.print(self._status_bar())
|
|
294
|
+
self._flush()
|
|
295
|
+
else:
|
|
296
|
+
with Live(
|
|
297
|
+
self._status_bar(),
|
|
298
|
+
console=self.console,
|
|
299
|
+
refresh_per_second=30,
|
|
300
|
+
screen=False,
|
|
301
|
+
) as live:
|
|
302
|
+
while thread.is_alive():
|
|
303
|
+
self._data_event.wait(timeout=0.1)
|
|
304
|
+
self._data_event.clear()
|
|
305
|
+
live.update(self._status_bar())
|
|
306
|
+
self._flush()
|
|
307
|
+
except KeyboardInterrupt:
|
|
308
|
+
self.console.print(f"\n[dim]{cancel_message}[/dim]")
|
|
309
|
+
self._flush()
|
|
310
|
+
finally:
|
|
311
|
+
self.agent_running = False
|
|
312
|
+
self.status = ""
|
|
313
|
+
self._data_event.set()
|
|
314
|
+
|
|
315
|
+
def _run_compact(self) -> None:
|
|
316
|
+
"""Compact the current conversation directly."""
|
|
317
|
+
result: dict[str, Any] = {}
|
|
318
|
+
|
|
319
|
+
def worker() -> None:
|
|
320
|
+
try:
|
|
321
|
+
ok, msg = self.session.compact_conversation()
|
|
322
|
+
result["ok"] = ok
|
|
323
|
+
result["msg"] = msg
|
|
324
|
+
except Exception as e: # noqa: BLE001 - surfaced to the user
|
|
325
|
+
result["ok"] = False
|
|
326
|
+
result["msg"] = f"Compaction failed: {e}"
|
|
327
|
+
|
|
328
|
+
self._run_with_status(
|
|
329
|
+
worker,
|
|
330
|
+
status_text=" ⏳ compacting",
|
|
331
|
+
cancel_message="compact cancelled — the result may still be applied",
|
|
332
|
+
)
|
|
333
|
+
ok = result.get("ok", False)
|
|
334
|
+
msg = result.get("msg", "Compaction failed: unknown error.")
|
|
335
|
+
if ok:
|
|
336
|
+
# The shared conversation was replaced: sync the TUI's own
|
|
337
|
+
# history too, or the next run would restart from the old
|
|
338
|
+
# full conversation and immediately re-compact it.
|
|
339
|
+
self.conversation_history = list(self.session.last_messages)
|
|
340
|
+
self._history_dirty = True
|
|
341
|
+
self.console.print(msg)
|
|
342
|
+
|
|
343
|
+
def _refresh_model_profiles(self) -> None:
|
|
344
|
+
"""Re-read the config file's ``models`` section so profiles
|
|
345
|
+
added/removed while the TUI is running show up on the next
|
|
346
|
+
/model call. A malformed config keeps the last loaded set."""
|
|
347
|
+
try:
|
|
348
|
+
self.session.model_profiles = config.load_models_config(self.session.config_path)
|
|
349
|
+
except ValueError as e:
|
|
350
|
+
self.console.print(f"[red]{e}[/red]")
|
|
351
|
+
|
|
352
|
+
def _model_list_names(self) -> list[str]:
|
|
353
|
+
"""Names shown by /model: the original default model (as
|
|
354
|
+
``default``) followed by every configured profile.
|
|
355
|
+
|
|
356
|
+
``default`` always restores the main ``llm`` settings the
|
|
357
|
+
session started with, so the original model stays reachable
|
|
358
|
+
after switching to profiles — and the listing count stays
|
|
359
|
+
stable across switches. Used by both the listing and the
|
|
360
|
+
numbered-selection paths so the numbers always match what was
|
|
361
|
+
displayed.
|
|
362
|
+
"""
|
|
363
|
+
return ["default", *sorted(self.session.model_profiles.keys())]
|
|
364
|
+
|
|
365
|
+
def _model_switch_by_name(self, name: str) -> None:
|
|
366
|
+
"""Switch to a named profile (or ``default``) and report the outcome."""
|
|
367
|
+
success, msg = self.session.switch_model(name)
|
|
368
|
+
if success:
|
|
369
|
+
self.console.print(f"[green]{msg}[/green]")
|
|
370
|
+
self._data_event.set()
|
|
371
|
+
else:
|
|
372
|
+
self.console.print(f"[red]{msg}[/red]")
|
|
373
|
+
|
|
374
|
+
def _run_model_command(self, arg: str) -> None:
|
|
375
|
+
"""Handle /model command for switching LLM profiles."""
|
|
376
|
+
self._refresh_model_profiles()
|
|
377
|
+
if not arg:
|
|
378
|
+
# List all models: the original default plus every profile
|
|
379
|
+
# currently in the config file
|
|
380
|
+
all_names = self._model_list_names()
|
|
381
|
+
current_model = self.session.model or "(unknown)"
|
|
382
|
+
default_model = (self.session.llm_settings or {}).get("model") or current_model
|
|
383
|
+
default_base_url = (self.session.llm_settings or {}).get(
|
|
384
|
+
"base_url"
|
|
385
|
+
) or self.session.client.base_url
|
|
386
|
+
|
|
387
|
+
self.console.print("\n[bold cyan]Available model profiles:[/bold cyan]")
|
|
388
|
+
if not self.session.model_profiles:
|
|
389
|
+
self.console.print(
|
|
390
|
+
"[yellow] (none configured — add a 'models' section to use /model)[/yellow]"
|
|
391
|
+
)
|
|
392
|
+
for idx, name in enumerate(all_names, 1):
|
|
393
|
+
if name == "default":
|
|
394
|
+
marker = " *" if current_model == default_model else ""
|
|
395
|
+
self.console.print(
|
|
396
|
+
f" [cyan]{idx})[/cyan] default{marker} — "
|
|
397
|
+
f"{default_model} @ {default_base_url}"
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
profile = self.session.model_profiles[name]
|
|
401
|
+
model_name = profile.get("model", "(inherited)")
|
|
402
|
+
base_url = profile.get("base_url", "(inherited)")
|
|
403
|
+
marker = " *" if model_name == current_model else ""
|
|
404
|
+
self.console.print(
|
|
405
|
+
f" [cyan]{idx})[/cyan] {name}{marker} — {model_name} @ {base_url}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
total_count = len(all_names)
|
|
409
|
+
self.console.print(f"\n[dim]Current: {current_model}[/dim]")
|
|
410
|
+
self.console.print(
|
|
411
|
+
f"[dim]Type a number (1-{total_count}) to switch, or enter a model name directly[/dim]\n"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Prompt user for selection
|
|
415
|
+
try:
|
|
416
|
+
selection = input("Select model: ").strip()
|
|
417
|
+
if not selection:
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Check if selection is a number
|
|
421
|
+
if selection.isdigit():
|
|
422
|
+
idx = int(selection) - 1
|
|
423
|
+
if 0 <= idx < len(all_names):
|
|
424
|
+
selected = all_names[idx]
|
|
425
|
+
if selected == "default" and current_model == default_model:
|
|
426
|
+
self.console.print("[yellow]Already using this model.[/yellow]")
|
|
427
|
+
else:
|
|
428
|
+
self._model_switch_by_name(selected)
|
|
429
|
+
else:
|
|
430
|
+
self.console.print(
|
|
431
|
+
f"[red]Invalid selection: {selection}. Choose 1-{len(all_names)}[/red]"
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
# Treat as model name
|
|
435
|
+
self._model_switch_by_name(selection)
|
|
436
|
+
except EOFError:
|
|
437
|
+
pass
|
|
438
|
+
except KeyboardInterrupt:
|
|
439
|
+
self.console.print("\n[dim]cancelled[/dim]")
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
# Check if arg is a number
|
|
443
|
+
if arg.strip().isdigit():
|
|
444
|
+
all_names = self._model_list_names()
|
|
445
|
+
idx = int(arg.strip()) - 1
|
|
446
|
+
if 0 <= idx < len(all_names):
|
|
447
|
+
selected = all_names[idx]
|
|
448
|
+
default_model = (self.session.llm_settings or {}).get("model") or (
|
|
449
|
+
self.session.model or ""
|
|
450
|
+
)
|
|
451
|
+
if selected == "default" and (self.session.model or "") == default_model:
|
|
452
|
+
self.console.print("[yellow]Already using this model.[/yellow]")
|
|
453
|
+
else:
|
|
454
|
+
self._model_switch_by_name(selected)
|
|
455
|
+
else:
|
|
456
|
+
self.console.print(
|
|
457
|
+
f"[red]Invalid selection: {arg}. Choose 1-{len(all_names)}[/red]"
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
# Switch to named model
|
|
461
|
+
self._model_switch_by_name(arg)
|
|
462
|
+
|
|
463
|
+
def _run_summary(self) -> None:
|
|
464
|
+
"""Append a summary of the conversation (tools disabled)."""
|
|
465
|
+
result: dict[str, str] = {}
|
|
466
|
+
|
|
467
|
+
def worker() -> None:
|
|
468
|
+
try:
|
|
469
|
+
result["msg"] = self.session.summarize_conversation()
|
|
470
|
+
except Exception as e: # noqa: BLE001 - surfaced to the user
|
|
471
|
+
result["msg"] = f"Summary failed: {e}"
|
|
472
|
+
|
|
473
|
+
self._run_with_status(
|
|
474
|
+
worker,
|
|
475
|
+
status_text=" ⏳ summarizing",
|
|
476
|
+
cancel_message="summary cancelled — the result may still be appended",
|
|
477
|
+
)
|
|
478
|
+
msg = result.get("msg", "Summary failed: unknown error.")
|
|
479
|
+
self.conversation_history = list(self.session.last_messages)
|
|
480
|
+
self._history_dirty = True
|
|
481
|
+
if msg == "Summary appended.":
|
|
482
|
+
last_msg = self.session.last_messages[-1]
|
|
483
|
+
if last_msg.role == "assistant" and last_msg.content:
|
|
484
|
+
self.console.print(last_msg.content)
|
|
485
|
+
else:
|
|
486
|
+
self.console.print(msg)
|
|
487
|
+
else:
|
|
488
|
+
self.console.print(msg)
|
|
489
|
+
|
|
490
|
+
def _run_sessions(self) -> None:
|
|
491
|
+
"""List saved sessions with metadata."""
|
|
492
|
+
files = SessionPersistence.list_sessions()
|
|
493
|
+
if not files:
|
|
494
|
+
self.console.print("[dim]no saved sessions[/dim]")
|
|
495
|
+
return
|
|
496
|
+
for f in files:
|
|
497
|
+
try:
|
|
498
|
+
with open(f, encoding="utf-8") as fh:
|
|
499
|
+
text = fh.read()
|
|
500
|
+
except OSError:
|
|
501
|
+
continue
|
|
502
|
+
meta = SessionPersistence.parse_metadata(text)
|
|
503
|
+
basename = os.path.basename(f)
|
|
504
|
+
model = meta.get("gptel-model", "?")
|
|
505
|
+
project = meta.get("python-agent-harness--project-dir", "?")
|
|
506
|
+
self.console.print(f" {basename:50s} model={model:20s} project={project}")
|
|
507
|
+
|
|
508
|
+
def _run_restore(self, arg: str) -> None:
|
|
509
|
+
"""Restore a saved session into the current TUI.
|
|
510
|
+
|
|
511
|
+
Usage: /restore <path> or /restore --latest or /restore latest
|
|
512
|
+
or /restore <title>
|
|
513
|
+
Loads the conversation history so the user can continue from
|
|
514
|
+
where they left off. When the argument is not a file path,
|
|
515
|
+
it is matched as a substring against session filenames/titles
|
|
516
|
+
(case-insensitive).
|
|
517
|
+
"""
|
|
518
|
+
path: str | None = None
|
|
519
|
+
if not arg or arg in ("--latest", "latest"):
|
|
520
|
+
path = SessionPersistence.latest_session()
|
|
521
|
+
elif os.path.isfile(arg):
|
|
522
|
+
path = arg
|
|
523
|
+
else:
|
|
524
|
+
# Try title-based matching: find sessions whose filename
|
|
525
|
+
# contains the argument as a case-insensitive substring
|
|
526
|
+
path = self._find_session_by_title(arg)
|
|
527
|
+
if not path:
|
|
528
|
+
self.console.print(
|
|
529
|
+
"[yellow]no session found "
|
|
530
|
+
"(use /restore <path>, /restore <title>, "
|
|
531
|
+
"/restore --latest, or /restore latest)[/yellow]"
|
|
532
|
+
)
|
|
533
|
+
return
|
|
534
|
+
if not os.path.isfile(path):
|
|
535
|
+
self.console.print(f"[red]file not found: {path}[/red]")
|
|
536
|
+
return
|
|
537
|
+
try:
|
|
538
|
+
with open(path, encoding="utf-8") as fh:
|
|
539
|
+
text = fh.read()
|
|
540
|
+
except OSError as e:
|
|
541
|
+
self.console.print(f"[red]cannot read {path}: {e}[/red]")
|
|
542
|
+
return
|
|
543
|
+
meta = SessionPersistence.parse_metadata(text)
|
|
544
|
+
body = SessionPersistence.strip_metadata(text)
|
|
545
|
+
# Rebuild conversation history from the saved markdown format
|
|
546
|
+
messages = self._parse_saved_body(body)
|
|
547
|
+
# Round timestamps are persisted in the metadata block; restore
|
|
548
|
+
# them so the dump separators keep their HH:MM:SS times.
|
|
549
|
+
round_times = meta.get("python-agent-harness--round-times")
|
|
550
|
+
if round_times:
|
|
551
|
+
try:
|
|
552
|
+
self._round_times = [float(x) for x in round_times.split()]
|
|
553
|
+
except ValueError:
|
|
554
|
+
self._round_times = []
|
|
555
|
+
else:
|
|
556
|
+
self._round_times = []
|
|
557
|
+
self.session.store.round_times = list(self._round_times)
|
|
558
|
+
# Update the session store to point at the restored file
|
|
559
|
+
self.session.store.file_path = path
|
|
560
|
+
title = title_from_filename(path)
|
|
561
|
+
if title:
|
|
562
|
+
self.session.store.title = title
|
|
563
|
+
# Replace conversation history: a new generation. Invalidate any
|
|
564
|
+
# worker still winding down from a cancelled run so its salvaged
|
|
565
|
+
# history can't clobber the restored session.
|
|
566
|
+
self.session.run_generation += 1
|
|
567
|
+
self.conversation_history = messages
|
|
568
|
+
self.session.last_messages = list(messages)
|
|
569
|
+
self.session.clear_todos()
|
|
570
|
+
self._history_dirty = True
|
|
571
|
+
model = meta.get("gptel-model", "?")
|
|
572
|
+
project = meta.get("python-agent-harness--project-dir", "?")
|
|
573
|
+
self.console.print(
|
|
574
|
+
f"[green]restored:[/green] {os.path.basename(path)} "
|
|
575
|
+
f"(model={model}, project={project}, {len(messages)} messages)"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def _parse_saved_body(body: str) -> list[Message]:
|
|
580
|
+
"""Parse a saved session body back into Message objects.
|
|
581
|
+
|
|
582
|
+
The save format is markdown with **role**: content blocks
|
|
583
|
+
separated by blank lines.
|
|
584
|
+
|
|
585
|
+
``tool`` blocks are dropped: the saved markdown does not keep
|
|
586
|
+
``tool_call_id``/``name`` (assistant tool calls are flattened to
|
|
587
|
+
plain text), so a restored ``role="tool"`` message would form an
|
|
588
|
+
API-invalid payload (a tool message with no preceding assistant
|
|
589
|
+
``tool_calls``). The following assistant reply already
|
|
590
|
+
summarizes the results, so dropping them loses no essential
|
|
591
|
+
context.
|
|
592
|
+
|
|
593
|
+
Body lines that merely look like a block header are escaped by
|
|
594
|
+
the renderer (see `escape_role_headers`) and unescaped here, so
|
|
595
|
+
a message quoting this format no longer splits into extra
|
|
596
|
+
messages. Sessions saved before escaping existed can still
|
|
597
|
+
split — that ambiguity is in the file, not in this parser.
|
|
598
|
+
"""
|
|
599
|
+
messages: list[Message] = []
|
|
600
|
+
current_role: str | None = None
|
|
601
|
+
current_lines: list[str] = []
|
|
602
|
+
|
|
603
|
+
for line in body.splitlines():
|
|
604
|
+
# Check for a role header: **user**: ... or **assistant**: ...
|
|
605
|
+
header = split_role_header(line)
|
|
606
|
+
if header is not None:
|
|
607
|
+
role, rest = header
|
|
608
|
+
# Save the previous block (tool blocks are dropped:
|
|
609
|
+
# their tool_call_id/name were not persisted)
|
|
610
|
+
if current_role is not None and current_role != "tool":
|
|
611
|
+
content = "\n".join(current_lines).strip()
|
|
612
|
+
if content:
|
|
613
|
+
messages.append(Message(role=current_role, content=content))
|
|
614
|
+
current_role = role
|
|
615
|
+
current_lines = [unescape_role_header(rest)]
|
|
616
|
+
continue
|
|
617
|
+
current_lines.append(unescape_role_header(line))
|
|
618
|
+
|
|
619
|
+
# Don't forget the last block (tool blocks are dropped)
|
|
620
|
+
if current_role is not None and current_role != "tool":
|
|
621
|
+
content = "\n".join(current_lines).strip()
|
|
622
|
+
if content:
|
|
623
|
+
messages.append(Message(role=current_role, content=content))
|
|
624
|
+
|
|
625
|
+
return messages
|
|
626
|
+
|
|
627
|
+
@staticmethod
|
|
628
|
+
def _find_session_by_title(query: str) -> str | None:
|
|
629
|
+
"""Find a session file by title substring (case-insensitive).
|
|
630
|
+
|
|
631
|
+
Matches against the full filename, the filename without .md,
|
|
632
|
+
and the derived title. Returns the most recent match, or None.
|
|
633
|
+
"""
|
|
634
|
+
query_lower = query.lower()
|
|
635
|
+
# Strip .md from query if present, for cleaner substring matching
|
|
636
|
+
query_stem = query_lower[:-3] if query_lower.endswith(".md") else query_lower
|
|
637
|
+
files = SessionPersistence.list_sessions() # already sorted by mtime desc
|
|
638
|
+
for f in files:
|
|
639
|
+
basename = os.path.basename(f)
|
|
640
|
+
basename_lower = basename.lower()
|
|
641
|
+
# Exact basename match (with or without .md)
|
|
642
|
+
if basename_lower == query_lower or basename_lower == query_lower + ".md":
|
|
643
|
+
return f
|
|
644
|
+
# Substring match against filename (minus .md)
|
|
645
|
+
name_part = basename[:-3] if basename.endswith(".md") else basename
|
|
646
|
+
if query_stem in name_part.lower():
|
|
647
|
+
return f
|
|
648
|
+
# Match against derived title (dashes → spaces)
|
|
649
|
+
title = title_from_filename(f)
|
|
650
|
+
if title and query_stem in title.lower():
|
|
651
|
+
return f
|
|
652
|
+
return None
|