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