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,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