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,781 @@
1
+ """Session: the runtime hub wiring tools, plan mode.
2
+
3
+ The session implements the ToolContext-facing API (sub-agents,
4
+ questions) and the agent-loop-facing API (client, calibrator, plan
5
+ mode, auto-save, notifications). The TUI layer subclasses it to
6
+ provide interactive confirmations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import os
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from . import config
19
+ from .client import Client
20
+ from .mcp.config import MCPConfig
21
+ from .mcp.manager import MCPManager
22
+ from .models import AgentMode
23
+ from .persistence import SessionPersistence, escape_role_headers
24
+ from .planmode import PlanMode
25
+ from .prompts import index_skills
26
+ from .subagent import run_subagent
27
+ from .token_estimator import TokenCalibrator
28
+ from .tools import Registry, ToolContext
29
+ from .tools.base import PendingToolResult
30
+ from .tools.filesystem import cleanup_spooled_files
31
+ from .tools.mcp import mcp_tools_from_manager
32
+
33
+
34
+ def find_skill_dir(project_dir: str, configured: str | None = None) -> str | None:
35
+ """Locate the skill directory.
36
+
37
+ If *configured* is set (``paths.skill_path`` in the config file), use
38
+ it directly. Otherwise fall back to the project's own ``skills/``
39
+ directory. Any other location must be configured explicitly — no
40
+ path outside the project is discovered implicitly.
41
+ """
42
+ if configured and os.path.isdir(configured):
43
+ return configured
44
+ cand = os.path.join(project_dir, "skills")
45
+ if os.path.isdir(cand):
46
+ return cand
47
+ return None
48
+
49
+
50
+ def find_context_dir(project_dir: str, configured: str | None = None) -> str | None:
51
+ """Locate the context directory.
52
+
53
+ If *configured* is set (``paths.context_path`` in the config file),
54
+ use it directly. Otherwise fall back to the project's own
55
+ ``contexts/`` directory. Any other location must be configured
56
+ explicitly — no path outside the project is discovered implicitly.
57
+ """
58
+ if configured and os.path.isdir(configured):
59
+ return configured
60
+ cand = os.path.join(project_dir, "contexts")
61
+ if os.path.isdir(cand):
62
+ return cand
63
+ return None
64
+
65
+
66
+ class Session:
67
+ """One interactive agent session (a "buffer" in elisp terms)."""
68
+
69
+ def __init__(
70
+ self,
71
+ project_dir: str,
72
+ client: Client,
73
+ model: str,
74
+ backend: str = "OpenAI-compatible",
75
+ system_prompt: str | None = None,
76
+ subagent_system_prompt: str | None = None,
77
+ temperature: float = config.TEMPERATURE,
78
+ max_tokens: int | None = config.MAX_TOKENS,
79
+ reasoning_effort: str | None = None,
80
+ stream: bool = True,
81
+ subagent_client: Client | None = None,
82
+ subagent_temperature: float | None = None,
83
+ subagent_max_tokens: int | None = None,
84
+ subagent_reasoning_effort: str | None = None,
85
+ subagent_stream: bool | None = None,
86
+ tool_names: list[str] | None = None,
87
+ registry: Registry | None = None,
88
+ context_path: str | None = None,
89
+ skill_path: str | None = None,
90
+ mcp: MCPConfig | None = None,
91
+ mcp_manager: MCPManager | None = None,
92
+ model_profiles: dict[str, dict] | None = None,
93
+ llm_settings: dict | None = None,
94
+ config_path: str | None = None,
95
+ ) -> None:
96
+ self.project_dir = project_dir
97
+ self.client = client
98
+ self.model = model
99
+ self.backend = backend
100
+ self.system_prompt = system_prompt
101
+ self.subagent_system_prompt = subagent_system_prompt
102
+ self.temperature = temperature
103
+ self.max_tokens = max_tokens
104
+ self.reasoning_effort = reasoning_effort
105
+ self.stream = stream
106
+ self.tools_enabled = True
107
+ self.alive = True
108
+ self._configured_context_path = context_path
109
+ self._configured_skill_path = skill_path
110
+ # Config file path (None = default resolution) — /model re-reads
111
+ # the ``models`` section from it on every invocation
112
+ self.config_path = config_path
113
+ # Sub-agent LLM: a dedicated client (base_url/api_key/model/
114
+ # timeout) and per-request options when a different LLM is
115
+ # configured for sub-agents (mirrors gptel-agent-harness-
116
+ # subagent-model/-backend); every unset option inherits the
117
+ # main agent's value. The sub-agent loop never uses this
118
+ # client directly — each Agent tool invocation clones it
119
+ # (see run_subagent) so concurrent sub-agents never share a
120
+ # Client's pool/abort state.
121
+ self.subagent_client = subagent_client or client
122
+ self.subagent_temperature = (
123
+ temperature if subagent_temperature is None else subagent_temperature
124
+ )
125
+ self.subagent_max_tokens = (
126
+ max_tokens if subagent_max_tokens is None else subagent_max_tokens
127
+ )
128
+ self.subagent_reasoning_effort = (
129
+ reasoning_effort if subagent_reasoning_effort is None else subagent_reasoning_effort
130
+ )
131
+ self.subagent_stream = stream if subagent_stream is None else subagent_stream
132
+
133
+ self.registry = registry or Registry()
134
+ # MCP (Model Context Protocol) integration: an optional adapter
135
+ # around the official SDK (requires the `mcp` extra). The MCP
136
+ # manager owns the server connections and one-time tool
137
+ # discovery; its tools are registered into the SAME registry as
138
+ # built-ins, so the agent loop never knows MCP exists.
139
+ self.mcp_manager = mcp_manager if mcp_manager is not None else MCPManager(mcp)
140
+ self.mcp_errors: list[tuple[str, str]] = []
141
+ self.calibrator = TokenCalibrator()
142
+ self.plan_mode = PlanMode(project_dir)
143
+ self.tool_ctx = ToolContext(self)
144
+ self._tool_diffs: dict[str, str] = {}
145
+ self._tool_diffs_lock = threading.Lock()
146
+ # thread-local: sub-agents each execute tools in their own
147
+ # background thread; the "currently executing call" that
148
+ # Edit/Write attach their diff to must be per-thread, or
149
+ # concurrent sub-agents would clobber each other's diff slot
150
+ self._active_call = threading.local()
151
+ # serializes interactive prompts (Question tool, PlanExit
152
+ # confirmation): the TUI can only ask one question at a time
153
+ self._interactive_lock = threading.Lock()
154
+ # dedicated per-invocation sub-agent clients (see run_subagent):
155
+ # concurrent sub-agents each run on their own Client clone, so
156
+ # one sub-agent's connection failure / abort can never tear
157
+ # down a sibling's in-flight request on a shared client. The
158
+ # active clones are tracked so cancel()/close() can reach them.
159
+ self._subagent_clients_lock = threading.Lock()
160
+ self._active_subagent_clients: list[Client] = []
161
+ self.store = SessionPersistence(
162
+ project_dir=project_dir,
163
+ model=model,
164
+ backend=backend,
165
+ system_prompt=system_prompt,
166
+ temperature=temperature,
167
+ max_tokens=max_tokens,
168
+ tool_names=tool_names or config.DEFAULT_TOOLS,
169
+ )
170
+
171
+ self.context_ratio: float | None = None
172
+ self.compacting = False
173
+ self.todos: list[dict] = []
174
+ self.pending_user_prompts: list[str] = []
175
+ self._save_error: str | None = None
176
+ self.last_messages: list = []
177
+ self.cancel_event = threading.Event()
178
+ # Named LLM profiles for runtime switching via /model command
179
+ self.model_profiles: dict[str, dict] = model_profiles or {}
180
+ # Resolved main llm settings (base for /model switching): a
181
+ # model profile's unset keys inherit these values, so switching
182
+ # between profiles never drifts settings from earlier switches.
183
+ self.llm_settings: dict = dict(llm_settings) if llm_settings else {}
184
+ # Monotonic cancel identity: cancel() bumps this counter, so a
185
+ # worker from a cancelled run can tell it was cancelled even
186
+ # after the next run clears the shared event.
187
+ self.cancel_generation = 0
188
+ # Monotonic run identity: bumped when a new top-level run starts
189
+ # (tui._start_agent). A worker whose captured value no longer
190
+ # matches is stale — superseded by a newer run — and must never
191
+ # touch shared state. Unlike cancel_generation this is NOT
192
+ # bumped by cancel(): a cancelled run with no successor still
193
+ # owns the session and may salvage its partial history.
194
+ self.run_generation = 0
195
+ self._skill_dir = self._find_skill_dir()
196
+ # (skill_dir, index) cache: rebuilt whenever the resolved skill
197
+ # directory changes (tests swap _skill_dir after construction)
198
+ self._skill_index_cache: tuple[str | None, dict[str, tuple[str, str]]] | None = None
199
+
200
+ # TUI hooks (overridden by the UI)
201
+ self.on_delta: Callable[[str], None] | None = None
202
+ self.log_fn: Callable[[str], None] | None = None
203
+ self.notify_fn: Callable[[str, Any], None] | None = None
204
+ self.confirm_fn: Callable[[str], bool] | None = None
205
+ self.ask_fn: Callable[[list[dict]], str] | None = None
206
+
207
+ # ------------------------------------------------------------------
208
+ # notifications
209
+ # ------------------------------------------------------------------
210
+ def notify(self, kind: str, data: Any = None) -> None:
211
+ if self.notify_fn:
212
+ self.notify_fn(kind, data)
213
+
214
+ def log(self, msg: str) -> None:
215
+ if self.log_fn:
216
+ self.log_fn(msg)
217
+
218
+ def confirm(self, prompt: str) -> bool:
219
+ with self._interactive_lock:
220
+ if self.confirm_fn:
221
+ return self.confirm_fn(prompt)
222
+ return True
223
+
224
+ def ask_questions(self, questions: list[dict]) -> str:
225
+ with self._interactive_lock:
226
+ if self.ask_fn:
227
+ return self.ask_fn(questions)
228
+ return "Unanswered"
229
+
230
+ # ------------------------------------------------------------------
231
+ # tools
232
+ # ------------------------------------------------------------------
233
+ def tool_specs(self, exclude: tuple[str, ...] = ()) -> list:
234
+ """Tool specs exposed to the model; ``exclude`` drops tools by
235
+ name (e.g. one-shot/interactive tools for sub-agent runs)."""
236
+ return [spec for spec in self.registry.specs() if spec.name not in exclude]
237
+
238
+ def execute_tool(
239
+ self, name: str, args: dict[str, Any], call_id: str | None = None
240
+ ) -> str | PendingToolResult:
241
+ """Execute a tool.
242
+
243
+ ``call_id`` (when given) lets Edit/Write attach a unified diff
244
+ for the TUI to render; retrieve it afterwards with
245
+ ``take_diff(call_id)``.
246
+ """
247
+ # plan-mode guard: only the plan file is writable. MCP tools
248
+ # are blocked too — the harness cannot verify what an external
249
+ # server's tool does (the README example config alone exposes
250
+ # write_file/create_directory), so plan mode stays read-only
251
+ # by refusing every mcp__ tool.
252
+ if self.plan_mode.is_plan and (
253
+ name in ("Write", "Edit", "Insert", "Mkdir", "Bash") or name.startswith("mcp__")
254
+ ):
255
+ blocked = self._plan_blocked(name, args)
256
+ if blocked:
257
+ return blocked
258
+
259
+ self._active_call.call_id = call_id
260
+ try:
261
+ result = self.registry.execute(name, args, self.tool_ctx)
262
+ finally:
263
+ self._active_call.call_id = None
264
+
265
+ self.notify("tool")
266
+ return result
267
+
268
+ def record_diff(self, diff_text: str) -> None:
269
+ """Attach a unified diff to the tool call currently executing."""
270
+ call_id = getattr(self._active_call, "call_id", None)
271
+ if call_id and diff_text:
272
+ with self._tool_diffs_lock:
273
+ self._tool_diffs[call_id] = diff_text
274
+
275
+ def take_diff(self, call_id: str) -> str | None:
276
+ """Pop and return the diff recorded for CALL_ID, if any."""
277
+ with self._tool_diffs_lock:
278
+ return self._tool_diffs.pop(call_id, None)
279
+
280
+ def _plan_blocked(self, name: str, args: dict[str, Any]) -> str | None:
281
+ if name.startswith("mcp__"):
282
+ return (
283
+ "Error: blocked by plan mode (read-only phase); "
284
+ "MCP tools are disabled — they may modify external state — "
285
+ "use Read/Glob/Grep for read-only access"
286
+ )
287
+ if name == "Bash":
288
+ return (
289
+ "Error: blocked by plan mode (read-only phase); "
290
+ "Bash is disabled — use Read/Glob/Grep for read-only access"
291
+ )
292
+ path = self._tool_path(name, args)
293
+ if path and path != self.plan_mode.plan_file:
294
+ # Edit diff-mode (no old_str, diff not explicitly False) runs
295
+ # `patch` which can write to arbitrary files via relative paths
296
+ # in the diff content — block it even if the target path looks
297
+ # innocent, because patch follows paths within the diff.
298
+ if name == "Edit" and args.get("old_str") is None and args.get("diff") is not False:
299
+ return (
300
+ "Error: blocked by plan mode (read-only phase); "
301
+ "diff/patch mode cannot target files other than the plan "
302
+ "file — use string replacement (old_str/new_str) instead"
303
+ )
304
+ return (
305
+ "Error: blocked by plan mode (read-only phase); only the plan file may be modified"
306
+ )
307
+ return None
308
+
309
+ def _tool_path(self, name: str, args: dict[str, Any]) -> str | None:
310
+ if name == "Write":
311
+ return os.path.realpath(
312
+ os.path.join(str(args.get("path", "")), str(args.get("filename", "")))
313
+ )
314
+ if name == "Edit":
315
+ return os.path.realpath(str(args.get("path", "")))
316
+ if name == "Insert":
317
+ return os.path.realpath(str(args.get("path", "")))
318
+ if name == "Mkdir":
319
+ return os.path.realpath(
320
+ os.path.join(str(args.get("parent", "")), str(args.get("name", "")))
321
+ )
322
+ return None
323
+
324
+ # ------------------------------------------------------------------
325
+ # MCP (optional; requires the `mcp` extra)
326
+ # ------------------------------------------------------------------
327
+ def connect_mcp(self) -> list[tuple[str, str]]:
328
+ """Connect the configured MCP servers and register their tools.
329
+
330
+ Call once when the session starts: discovery happens ONCE (not
331
+ per turn) and the resulting tools are ordinary registry tools
332
+ from then on. Failures are per-server and non-fatal — the
333
+ session keeps working with the servers that did connect. The
334
+ returned ``[(server, error)]`` list is also stored in
335
+ ``self.mcp_errors`` and logged.
336
+ """
337
+ if not self.mcp_manager.config.servers:
338
+ return []
339
+ failures = self.mcp_manager.connect_all()
340
+ discovered = self.mcp_manager.discover_tools()
341
+ for tool in mcp_tools_from_manager(self.mcp_manager):
342
+ self.registry.register(tool)
343
+ self.mcp_errors = list(failures) + [e for e in self.mcp_manager.errors if e not in failures]
344
+ for server, err in self.mcp_errors:
345
+ self.log(f"MCP: [{server}] {err}")
346
+ if discovered:
347
+ self.log(
348
+ f"MCP: registered {len(discovered)} tool(s) from "
349
+ f"{len(self.mcp_manager.connected)} server(s)"
350
+ )
351
+ self.notify("mcp")
352
+ return failures
353
+
354
+ # ------------------------------------------------------------------
355
+ # ToolContext-facing API
356
+ # ------------------------------------------------------------------
357
+ def update_todos(self, todos: list[dict]) -> None:
358
+ """Store TODOS so the pinned TUI panel shows the current list."""
359
+ self.todos = list(todos)
360
+ self.notify("todos")
361
+
362
+ def clear_todos(self) -> None:
363
+ """Drop the todo list (e.g. session cleared or restored)."""
364
+ self.todos = []
365
+ self.notify("todos")
366
+
367
+ def find_skill(self, name: str) -> str | None:
368
+ """Resolve a skill by its frontmatter ``name`` (opencode-style).
369
+
370
+ The skill index is built from every ``SKILL.md`` under the skill
371
+ directory, keyed by the ``name`` in each file's frontmatter —
372
+ the same names advertised in the system prompt — so directory
373
+ names never matter and path-traversal inputs are inert (lookup
374
+ is a plain dict hit against scanned paths only).
375
+ """
376
+ if not self._skill_dir:
377
+ return None
378
+ index = self._skill_index()
379
+ hit = index.get(name)
380
+ return hit[0] if hit else None
381
+
382
+ def _skill_index(self) -> dict[str, tuple[str, str]]:
383
+ """Return the cached name -> (path, description) skill index."""
384
+ key = self._skill_dir
385
+ if self._skill_index_cache is None or self._skill_index_cache[0] != key:
386
+ self._skill_index_cache = (key, index_skills(key))
387
+ return self._skill_index_cache[1]
388
+
389
+ def _find_skill_dir(self) -> str | None:
390
+ return find_skill_dir(self.project_dir, self._configured_skill_path)
391
+
392
+ def run_subagent(self, subagent_type: str, description: str, prompt: str) -> str:
393
+ """Run a delegated sub-agent task.
394
+
395
+ The sub-agent has no TodoWrite (parent-only), so it can never
396
+ touch the parent's todo list.
397
+
398
+ Each invocation runs on a DEDICATED client, cloned from the
399
+ configured sub-agent client: concurrent Agent tool calls share
400
+ this session, and a shared Client would race — ``_reset_http``
401
+ / ``abort`` swap and close the underlying httpx pool and
402
+ ``_aborted`` is per-request state, so one sub-agent's
403
+ connection failure (or a Ctrl-C) would tear down a sibling's
404
+ in-flight request. The clone is tracked for cancel/close and
405
+ released when the sub-agent finishes.
406
+ """
407
+ client, owned = self._new_subagent_client()
408
+ if owned:
409
+ with self._subagent_clients_lock:
410
+ self._active_subagent_clients.append(client)
411
+ try:
412
+ return run_subagent(self, description, prompt, client=client)
413
+ finally:
414
+ if owned:
415
+ with self._subagent_clients_lock:
416
+ if client in self._active_subagent_clients:
417
+ self._active_subagent_clients.remove(client)
418
+ client.close()
419
+
420
+ def _new_subagent_client(self) -> tuple[Any, bool]:
421
+ """A dedicated Client for one sub-agent invocation.
422
+
423
+ Real Clients are cloned (fresh httpx pool, own ``_aborted``
424
+ flag, same endpoint/credentials/log). A non-Client
425
+ ``subagent_client`` (a test double) is passed through
426
+ untouched — the isolation concern does not apply to it, and
427
+ custom clients keep working as-is.
428
+ """
429
+ base = self.subagent_client
430
+ if isinstance(base, Client):
431
+ return base.clone(), True
432
+ return base, False
433
+
434
+ def plan_exit(self) -> str:
435
+ """PlanExit tool implementation.
436
+
437
+ Asks the user to approve the plan→build switch through a y/n
438
+ confirmation UI (rendered like the Question tool's choice list,
439
+ but keyed with y/n instead of numbers). The TUI hook decides
440
+ the exact look; the session only interprets the boolean answer.
441
+ """
442
+ if not self.plan_mode.is_plan:
443
+ return "Not in plan mode; PlanExit has no effect. Continue as normal."
444
+ approved = self.confirm(
445
+ f"Plan at {self.plan_mode.plan_file} is complete. "
446
+ "Switch to build agent and start implementing?"
447
+ )
448
+ if approved:
449
+ self.switch_to_build()
450
+ msg = config.PLAN_EXIT_APPROVED_MESSAGE % (self.plan_mode.plan_file or "")
451
+ self.pending_user_prompts.append(msg)
452
+ return (
453
+ "User approved switching to build agent. You are now in "
454
+ "build mode; proceed to execute the approved plan."
455
+ )
456
+ return (
457
+ "User rejected switching to build. Remain in plan mode: keep "
458
+ "planning and refining the plan file, and do NOT edit any other files."
459
+ )
460
+
461
+ # ------------------------------------------------------------------
462
+ # mode switching
463
+ # ------------------------------------------------------------------
464
+ def switch_to_build(self) -> None:
465
+ self.plan_mode.set_mode(AgentMode.BUILD, self._mode_prompts())
466
+ self.registry.unregister("PlanExit")
467
+
468
+ def switch_to_plan(self) -> None:
469
+ from .tools import PlanExit
470
+
471
+ self.plan_mode.set_mode(AgentMode.PLAN, self._mode_prompts())
472
+ self.registry.register(PlanExit())
473
+
474
+ def _mode_prompts(self) -> dict[str, str]:
475
+ from .prompts import read_prompt_file
476
+
477
+ return {
478
+ "plan": read_prompt_file("plan.md"),
479
+ "plan-mode": read_prompt_file("plan-mode.md"),
480
+ "build-switch": read_prompt_file("build-switch.md"),
481
+ }
482
+
483
+ # ------------------------------------------------------------------
484
+ # session persistence hooks
485
+ # ------------------------------------------------------------------
486
+ def remember_user_text(self, messages: list) -> None:
487
+ """Remember the last real user message for session-title generation.
488
+
489
+ Skips harness-injected messages (nudges, plan/build-switch
490
+ reminders, queued mode prompts — flagged ``injected``) so a
491
+ title is never generated from "Review the original user request
492
+ and the Task Completion Rules…" or a mode-switch reminder.
493
+ """
494
+ nudge = config.NUDGE_MESSAGE
495
+ for m in reversed(messages):
496
+ if m.role == "user" and not m.injected and m.text() != nudge:
497
+ self.store.remember_first_user_message(m.text())
498
+ break
499
+
500
+ def auto_save(self, messages: list, system: str | None) -> None:
501
+ if not config.AUTO_SAVE_SESSION:
502
+ return
503
+ text = self._conversation_text(messages)
504
+ # retry once: transient failures (NFS hiccup, brief lock) clear
505
+ # on the second attempt; permanent ones (disk full, read-only)
506
+ # fail again and leave a persistent, visible error state instead
507
+ # of silently dropping the session
508
+ for attempt in (1, 2):
509
+ try:
510
+ self.store.save(text)
511
+ self._save_error = None
512
+ return
513
+ except OSError as e:
514
+ self._save_error = str(e)
515
+ if attempt == 1:
516
+ time.sleep(0.2)
517
+ self.log(f"auto-save failed: {self._save_error}")
518
+ self.notify("save-error")
519
+
520
+ def generate_session_title(self) -> None:
521
+ """Generate a title from the first real user message (title.md).
522
+
523
+ Mirrors gptel-agent-harness--generate-session-title: one-shot per
524
+ session (guarded by store.title / title_pending); on success the
525
+ session file is renamed to <title>_<TS>.md.
526
+
527
+ Reasoning models answer with a reasoning preamble; the client
528
+ merges it ahead of the real answer, so it is stripped here or
529
+ the first 50 chars of the reasoning would become the session
530
+ name. The session temperature is passed so the title request
531
+ matches the buffer settings (elisp parity) instead of the API
532
+ default.
533
+ """
534
+ store = self.store
535
+ if store.title or store.title_pending:
536
+ return
537
+ first = store.first_user_message()
538
+ if not first:
539
+ return
540
+ store.title_pending = True
541
+ try:
542
+ from .models import Message as Msg
543
+ from .prompts import read_prompt_file
544
+
545
+ system = read_prompt_file("title.md")
546
+ resp, _ = self.client.chat_sync(
547
+ [Msg(role="user", content=first)],
548
+ system=system,
549
+ temperature=self.temperature,
550
+ )
551
+ title = resp.text_without_reasoning()
552
+ if title:
553
+ store.apply_title(title)
554
+ if self.store.title:
555
+ self.log(f"session titled — {self.store.title}")
556
+ except Exception as e: # noqa: BLE001 - title failure is non-fatal
557
+ self.log(f"title generation failed: {e}")
558
+ finally:
559
+ store.title_pending = False
560
+
561
+ def _conversation_text(self, messages: list) -> str:
562
+ parts: list[str] = []
563
+ for m in messages:
564
+ # escaped: a body line that looks like a `**role**: ` block
565
+ # header would otherwise split the message on restore
566
+ body = escape_role_headers(m.text())
567
+ if m.role == "assistant" and m.tool_calls:
568
+ calls = ", ".join(tc.name for tc in m.tool_calls)
569
+ body = (body + f"\n[tool calls: {calls}]").strip()
570
+ if body:
571
+ parts.append(f"**{m.role}**: {body}")
572
+ return "\n\n".join(parts)
573
+
574
+ def close(self) -> None:
575
+ self.cancel()
576
+ self.alive = False
577
+ cleanup_spooled_files()
578
+ # MCP server connections + event-loop thread (no-op when no MCP
579
+ # servers are configured or none connected)
580
+ self.mcp_manager.close_all()
581
+ if hasattr(self.client, "close"):
582
+ self.client.close()
583
+ if self.subagent_client is not self.client and hasattr(self.subagent_client, "close"):
584
+ self.subagent_client.close()
585
+ # defensive: sub-agent workers close their own clones in
586
+ # run_subagent's finally; close any stragglers (e.g. a worker
587
+ # still winding down after cancel) so no pool leaks
588
+ with self._subagent_clients_lock:
589
+ strays = list(self._active_subagent_clients)
590
+ self._active_subagent_clients.clear()
591
+ for c in strays:
592
+ if hasattr(c, "close"):
593
+ with contextlib.suppress(Exception): # best effort
594
+ c.close()
595
+
596
+ def cancel(self) -> None:
597
+ """Cancel the in-flight agent run (Ctrl-C).
598
+
599
+ Sets the cancel event (checked by the agent loop) and aborts the
600
+ active HTTP stream so a blocking read unblocks immediately. The
601
+ loop turns this into a clean stop, not an error.
602
+
603
+ The generation counter makes the cancellation stick to the run
604
+ that was active: a stale worker finishing late (e.g. after a
605
+ long tool call) stays cancelled even once the next run clears
606
+ the shared event, so it can never clobber the new run's state.
607
+ """
608
+ self.cancel_event.set()
609
+ self.cancel_generation += 1
610
+ # A sub-agent streams on its own client when a separate LLM is
611
+ # configured — abort BOTH pools so a blocked sub-agent read is
612
+ # interrupted too (see Client.abort for why close() alone is
613
+ # not enough). A shared client is aborted once; dedicated
614
+ # per-invocation sub-agent clones (see run_subagent) are each
615
+ # aborted so every in-flight sub-agent request is interrupted.
616
+ clients = [self.client]
617
+ if self.subagent_client is not self.client:
618
+ clients.append(self.subagent_client)
619
+ with self._subagent_clients_lock:
620
+ clients.extend(self._active_subagent_clients)
621
+ for c in clients:
622
+ if hasattr(c, "abort"):
623
+ with contextlib.suppress(Exception): # best effort
624
+ c.abort()
625
+
626
+ # ------------------------------------------------------------------
627
+ # model switching
628
+ # ------------------------------------------------------------------
629
+ def switch_model(self, name: str) -> tuple[bool, str]:
630
+ """Switch to a named LLM profile.
631
+
632
+ Model-specific settings take precedence over the main ``llm``
633
+ config; keys the profile leaves unset inherit the main ``llm``
634
+ settings as resolved at session start (so switching between
635
+ profiles never drifts values from earlier switches). The
636
+ pseudo-profile ``default`` restores those original main ``llm``
637
+ settings, so the model active at session start stays reachable
638
+ after any number of switches. The client and session are
639
+ updated in place. Returns (success, message).
640
+ """
641
+ if name == "default":
642
+ profile = None
643
+ elif not self.model_profiles or name not in self.model_profiles:
644
+ available = (
645
+ ", ".join(sorted(["default", *self.model_profiles.keys()]))
646
+ if self.model_profiles
647
+ else "default"
648
+ )
649
+ return False, f"unknown model: {name} (available: {available})"
650
+ else:
651
+ profile = self.model_profiles[name]
652
+ # Effective settings: main llm config (resolved at session
653
+ # start) overlaid with the profile's own settings. Profile
654
+ # keys that are set (not None) win; unset keys inherit the llm
655
+ # config, which itself falls back to the current session values
656
+ # for callers that don't pass llm_settings.
657
+ merged = dict(self.llm_settings)
658
+ current = {
659
+ "base_url": self.client.base_url,
660
+ "api_key": self.client.api_key,
661
+ "model": self.model,
662
+ "backend": self.backend,
663
+ "temperature": self.temperature,
664
+ "max_tokens": self.max_tokens,
665
+ "timeout": self.client.timeout,
666
+ "reasoning_effort": self.reasoning_effort,
667
+ "stream": self.stream,
668
+ }
669
+ for key, val in current.items():
670
+ merged.setdefault(key, val)
671
+ if profile is not None:
672
+ for key in (
673
+ "base_url",
674
+ "api_key",
675
+ "model",
676
+ "backend",
677
+ "temperature",
678
+ "max_tokens",
679
+ "timeout",
680
+ "reasoning_effort",
681
+ "stream",
682
+ ):
683
+ if key in profile and profile[key] is not None:
684
+ merged[key] = profile[key]
685
+ self.client.base_url = str(merged["base_url"]).rstrip("/")
686
+ self.client.api_key = merged["api_key"]
687
+ self.client.model = merged["model"]
688
+ self.model = merged["model"]
689
+ self.store.model = merged["model"]
690
+ self.backend = merged["backend"]
691
+ self.store.backend = merged["backend"]
692
+ self.temperature = merged["temperature"]
693
+ self.max_tokens = merged["max_tokens"]
694
+ if hasattr(self.client, "set_timeout"):
695
+ self.client.set_timeout(merged["timeout"])
696
+ else:
697
+ self.client.timeout = merged["timeout"]
698
+ self.reasoning_effort = merged["reasoning_effort"]
699
+ self.stream = merged["stream"]
700
+ return True, f"switched to {name} ({self.model})"
701
+
702
+ # ------------------------------------------------------------------
703
+ # direct commands: compact / summary (no agent loop)
704
+ # ------------------------------------------------------------------
705
+ def compact_conversation(self) -> tuple[bool, str]:
706
+ """Compact the current conversation in place.
707
+
708
+ Mirrors gptel-agent-harness-commands-compact-buffer: the whole
709
+ conversation is sent as the user message with the compact prompt
710
+ as system (tools/stream disabled); on success the conversation is
711
+ replaced by the summary frame followed by every real user prompt
712
+ (nudges and other harness-injected messages excluded), so the
713
+ actual requests survive the compaction. The automatic path
714
+ (``AgentLoop.compact``) does the same and resumes the run; the
715
+ manual command just replaces the history and waits for the next
716
+ user message.
717
+ """
718
+ from .prompts import compact_summary, compacted_messages, user_prompt_texts
719
+
720
+ # Replacing the conversation is a new generation: invalidate any
721
+ # worker still winding down from a cancelled run, or its
722
+ # salvaged-history commit would clobber the compacted buffer.
723
+ self.run_generation += 1
724
+ messages = self.last_messages or []
725
+ if not messages:
726
+ return False, "Nothing to compact."
727
+ if self.compacting:
728
+ return False, "Compaction already in progress."
729
+ self.compacting = True
730
+ try:
731
+ conversation = self._conversation_text(messages)
732
+ summary = compact_summary(self.client, conversation)
733
+ if not summary:
734
+ return False, "Compaction failed: empty summary."
735
+ # The summary is part of the user turn (the original system
736
+ # prompt is passed separately and stays untouched), so it
737
+ # replaces the history as a user message — matching the
738
+ # elisp flow where the compacted summary is plain buffer
739
+ # text sent as the user prompt. Every real user prompt
740
+ # (nudges and other harness-injected messages excluded) is
741
+ # preserved verbatim after the frame, so the model keeps
742
+ # the actual requests.
743
+ self.last_messages = compacted_messages(summary, user_prompt_texts(messages))
744
+ self.auto_save(self.last_messages, self.system_prompt)
745
+ self.notify("compact")
746
+ return True, "Buffer compacted successfully."
747
+ except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
748
+ self.log(f"compaction failed: {e}")
749
+ return False, f"Compaction failed: {e}"
750
+ finally:
751
+ self.compacting = False
752
+
753
+ def summarize_conversation(self) -> str:
754
+ """Append a summary of the conversation (tools disabled).
755
+
756
+ Mirrors gptel-agent-harness-commands-summary: the conversation
757
+ text is sent with the summary prompt, and the result is appended
758
+ as an assistant message plus a session save.
759
+ """
760
+ from .models import Message as Msg
761
+ from .prompts import read_prompt_file
762
+
763
+ # Appending to the shared conversation is a new generation: invalidate
764
+ # any worker still winding down from a cancelled run, or its
765
+ # salvaged-history commit would clobber the appended summary.
766
+ self.run_generation += 1
767
+ messages = self.last_messages or []
768
+ if not messages:
769
+ return "Nothing to summarize."
770
+ conversation = self._conversation_text(messages)
771
+ system = read_prompt_file("summary.md")
772
+ try:
773
+ resp, _ = self.client.chat_sync([Msg(role="user", content=conversation)], system=system)
774
+ summary = resp.text_without_reasoning()
775
+ except Exception as e: # noqa: BLE001
776
+ return f"Summary failed: {e}"
777
+ if not summary:
778
+ return "Summary failed: empty response."
779
+ self.last_messages.append(Msg(role="assistant", content=summary))
780
+ self.auto_save(self.last_messages, self.system_prompt)
781
+ return "Summary appended."