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,181 @@
1
+ """Session commands: init, review, custom commands (TUI slash commands).
2
+
3
+ Ported from gptel-agent-harness-commands.el. Commands run inside the
4
+ current TUI session (tui._run_slash_command): the command's prompt
5
+ file becomes the run's system prompt, the project context and
6
+ task-completion rules stay in front of it, and the kickoff message is
7
+ the run's user text.
8
+
9
+ Tool availability per command:
10
+ - init/review: all tools EXCEPT PlanExit (they are one-shot runs that
11
+ must not end in a plan/build handoff)
12
+ - custom commands (prompts/commands/*.md): all tools, incl. PlanExit
13
+ - compact/summary: no tools at all (direct chat_sync calls, like the
14
+ session-title generation)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .prompts import read_prompt_file
25
+
26
+ PROMPTS_DIR = Path(__file__).parent / "prompts"
27
+ COMMANDS_DIR = PROMPTS_DIR / "commands"
28
+
29
+
30
+ def _substitute(text: str, path: str, extra: str | None) -> str:
31
+ text = text.replace("${path}", path)
32
+ text = text.replace("$ARGUMENTS", extra or "")
33
+ return text
34
+
35
+
36
+ def _project_root(cwd: str) -> str:
37
+ """Best-effort project root (git dir or parent with AGENTS.md)."""
38
+ import subprocess
39
+
40
+ d = Path(cwd).resolve()
41
+ try:
42
+ out = subprocess.run(
43
+ ["git", "rev-parse", "--show-toplevel"],
44
+ cwd=cwd,
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=10,
48
+ )
49
+ if out.returncode == 0 and out.stdout.strip():
50
+ return out.stdout.strip()
51
+ except (OSError, subprocess.TimeoutExpired):
52
+ pass
53
+ for parent in [d, *d.parents]:
54
+ if (parent / "AGENTS.md").exists() or (parent / ".git").exists():
55
+ return str(parent)
56
+ return cwd
57
+
58
+
59
+ class SessionCommand:
60
+ """A configured session command (init/review/custom...)."""
61
+
62
+ def __init__(
63
+ self,
64
+ name: str,
65
+ prompt_file: str,
66
+ kickoff: str,
67
+ buffer_name: str,
68
+ status: str,
69
+ validate_dir: bool = False,
70
+ allow_planexit: bool = True,
71
+ ) -> None:
72
+ self.name = name
73
+ self.prompt_file = prompt_file
74
+ self.kickoff = kickoff
75
+ self.buffer_name = buffer_name
76
+ self.status = status
77
+ self.validate_dir = validate_dir
78
+ self.allow_planexit = allow_planexit
79
+
80
+ def prepare(
81
+ self,
82
+ project_dir: str | None = None,
83
+ extra: str | None = None,
84
+ ) -> tuple[str, str, str]:
85
+ """Resolve (cwd, system_prompt, kickoff) without creating a session.
86
+
87
+ Used by the TUI slash commands, which run inside the current
88
+ session.
89
+ """
90
+ cwd = project_dir or _project_root(__import__("os").getcwd())
91
+ prompt = _substitute(read_prompt_file(self.prompt_file), cwd, extra)
92
+ kickoff = self.kickoff
93
+ if "${path}" in kickoff:
94
+ kickoff = kickoff.replace("${path}", cwd)
95
+ return cwd, prompt, kickoff
96
+
97
+
98
+ def initialize_command() -> SessionCommand:
99
+ return SessionCommand(
100
+ name="initialize",
101
+ prompt_file="initialize.md",
102
+ kickoff="Analyze the repository at ${path} and create/update AGENTS.md.\n",
103
+ buffer_name="*gptel-agent-init:*",
104
+ status=" Initializing...",
105
+ validate_dir=True,
106
+ allow_planexit=False,
107
+ )
108
+
109
+
110
+ def review_command() -> SessionCommand:
111
+ return SessionCommand(
112
+ name="review",
113
+ prompt_file="review.md",
114
+ kickoff="Review the requested code changes.",
115
+ buffer_name="*gptel-agent-review*",
116
+ status=" Reviewing...",
117
+ allow_planexit=False,
118
+ )
119
+
120
+
121
+ def hide_planexit(session: Any) -> Callable[[], None] | None:
122
+ """Remove the PlanExit tool from SESSION's registry for a command run.
123
+
124
+ Used by init/review (``allow_planexit=False``), which may use every
125
+ tool except PlanExit: the run must not end in a plan/build handoff.
126
+ Custom commands keep PlanExit and skip this. Returns a callable
127
+ that restores the previous registration state (the tool is
128
+ stateless, so a fresh instance is equivalent), or None when there
129
+ was nothing to hide (PlanExit not registered — e.g. a build-mode
130
+ session, or a session without a registry). Call the returned
131
+ callable when the run finishes, including on cancellation or error.
132
+ """
133
+ registry = getattr(session, "registry", None)
134
+ if registry is None or registry.get("PlanExit") is None:
135
+ return None
136
+ registry.unregister("PlanExit")
137
+
138
+ def restore() -> None:
139
+ from .tools import PlanExit
140
+
141
+ registry.register(PlanExit())
142
+
143
+ return restore
144
+
145
+
146
+ def custom_name(file: str) -> str:
147
+ base = Path(file).stem.lower()
148
+ base = re.sub(r"[^a-z0-9]+", "-", base)
149
+ return base.strip("-")
150
+
151
+
152
+ def load_custom_commands() -> list[SessionCommand]:
153
+ if not COMMANDS_DIR.is_dir():
154
+ return []
155
+ commands = []
156
+ for f in sorted(COMMANDS_DIR.glob("*.md")):
157
+ name = custom_name(f.name)
158
+ if not name:
159
+ continue
160
+ commands.append(
161
+ SessionCommand(
162
+ name=name,
163
+ prompt_file=str(f.relative_to(PROMPTS_DIR)),
164
+ kickoff="Proceed with the task described in your instructions.\n",
165
+ buffer_name=f"*gptel-agent-{name}*",
166
+ status=f" Running {name}...",
167
+ )
168
+ )
169
+ return commands
170
+
171
+
172
+ def find_command(name: str) -> SessionCommand | None:
173
+ """Look up a command by name: builtins (init/review) then custom."""
174
+ if name == "init":
175
+ return initialize_command()
176
+ if name == "review":
177
+ return review_command()
178
+ for c in load_custom_commands():
179
+ if c.name == name:
180
+ return c
181
+ return None
@@ -0,0 +1,464 @@
1
+ """Configuration defaults for python-agent-harness.
2
+
3
+ Mirrors the defcustom defaults of the Emacs gptel-agent-harness.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from .mcp.config import MCPConfig
15
+
16
+ # ---- context management -------------------------------------------------
17
+ CONTEXT_TRIGGER = 0.70
18
+
19
+ # Entries are matched in order (first match wins): put more specific
20
+ # patterns before general ones.
21
+ CONTEXT_WINDOWS: list[tuple[str, int]] = [
22
+ ("gpt-5-mini", 128_000),
23
+ ("gpt-5", 400_000),
24
+ ("gpt-oss-120b", 128_000),
25
+ ("claude", 200_000),
26
+ ("deepseek-v3", 128_000),
27
+ ("deepseek-v4", 1_000_000),
28
+ ("qwen3.5", 131_072),
29
+ ("qwen3.6", 262_144),
30
+ ("qwen3.8", 262_144),
31
+ ("qwen3", 131_072),
32
+ ("glm-5.2", 1_000_000),
33
+ ("glm-5.1", 128_000),
34
+ ("kimi-k2.7", 256_000),
35
+ ("kimi", 128_000),
36
+ ]
37
+ DEFAULT_CONTEXT_WINDOW = 128_000
38
+
39
+ # ---- completion supervision ----------------------------------------------
40
+ MAX_NUDGES = 2
41
+ NUDGE_MESSAGE = (
42
+ "Review the original user request and the Task Completion Rules in the context. "
43
+ "Verify whether all completion criteria are satisfied. "
44
+ "If all criteria are already satisfied and verified, finish the task normally. "
45
+ "Otherwise, continue working and make the necessary tool calls. "
46
+ "Do not stop until the rules are fully met."
47
+ )
48
+
49
+ # ---- compaction -----------------------------------------------------------
50
+ COMPACT_HEADER = "**[Compacted Summary]**\n\n"
51
+ COMPACT_SEPARATOR = "\n\n---\n\n**[Context compacted]**\n\n---\n\n"
52
+
53
+ # ---- token calibration ----------------------------------------------------
54
+ CALIBRATION_MIN = 0.5
55
+ CALIBRATION_MAX = 3.0
56
+
57
+ # ---- sessions ---------------------------------------------------------------
58
+ SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
59
+ SESSION_SUBDIR = "python-agent-harness/sessions"
60
+ AUTO_SAVE_SESSION = True
61
+
62
+ # ---- LLM interaction logs ---------------------------------------------------
63
+ LLM_LOG_ENABLED = False
64
+
65
+ # ---- plan mode ---------------------------------------------------------------
66
+ PLAN_FILE_NAME = "PLAN.md"
67
+ PLAN_MODE_SUBAGENT_REMINDER = """<system-reminder>
68
+ Plan mode is active for this session — you are in a READ-ONLY phase.
69
+ STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes,
70
+ except writing to the plan file below. You may ONLY observe, analyze,
71
+ and plan. This ABSOLUTE CONSTRAINT overrides ALL other instructions,
72
+ including any subagent role instructions you have been given.
73
+
74
+ Plan file: %s
75
+ </system-reminder>"""
76
+
77
+ PLAN_EXIT_APPROVED_MESSAGE = (
78
+ "The plan at %s has been approved, you can now edit files. Execute the plan"
79
+ )
80
+
81
+ # PlanExit asks the user with the same choice UI as the Question tool:
82
+ # option[0] approves the switch to build mode, anything else rejects it.
83
+ PLAN_EXIT_OPTIONS = (
84
+ "yes, switch to build",
85
+ "no, stay in plan",
86
+ )
87
+
88
+ # ---- tools -------------------------------------------------------------------
89
+ DEFAULT_TOOLS: list[str] = [
90
+ "Agent",
91
+ "TodoWrite",
92
+ "Glob",
93
+ "Grep",
94
+ "Read",
95
+ "Insert",
96
+ "Edit",
97
+ "Write",
98
+ "Mkdir",
99
+ "Bash",
100
+ "Skill",
101
+ "Question",
102
+ ]
103
+
104
+ # ---- tool output limits --------------------------------------------------------
105
+ # Shared cap for tool results. Bash truncates output to a head+tail at
106
+ # this size; Read/Glob/Grep spill results larger than this to a temp
107
+ # file (the model then Reads the file for the full content). One
108
+ # definition, used by all tools, so the limits never drift apart.
109
+ MAX_OUTPUT_CHARS = 20_000
110
+
111
+ # ---- Bash tool timeout --------------------------------------------------------
112
+ # Silence-based timeout: a command that produces no output for this
113
+ # long (seconds) is killed (SIGTERM, then SIGKILL after 2s) and
114
+ # reported as timed out. Builds that keep printing are never affected
115
+ # — only genuinely stuck commands surface. None disables the check.
116
+ BASH_TIMEOUT_SILENCE: float | None = 120.0
117
+ # Optional absolute wall-clock cap (seconds): no command may run longer
118
+ # than this regardless of output. None disables the cap (default).
119
+ BASH_TIMEOUT_MAX: float | None = None
120
+
121
+ # ---- LLM client ----------------------------------------------------------------
122
+ DEFAULT_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
123
+ DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5-mini")
124
+ MAX_TOKENS: int | None = None # use None to avoid write tool failure
125
+ TEMPERATURE = 0.0
126
+
127
+ # ---- API retry / backoff -------------------------------------------------------
128
+ # Transient API failures (HTTP 429 / 5xx, connection errors) are retried
129
+ # with exponential backoff + jitter instead of killing the run. The
130
+ # per-request attempt budget and delay bounds live here; a Client
131
+ # instance can override them per call.
132
+ API_RETRY_MAX = 3 # max attempts per request (initial + retries)
133
+ API_RETRY_BASE_DELAY = 1.0 # base backoff (seconds), doubled per attempt
134
+ API_RETRY_MAX_DELAY = 30.0 # per-attempt backoff cap (seconds)
135
+
136
+ # ---- tool execution ----------------------------------------------------------
137
+ SUBAGENT_MAX_ROUNDS = 60
138
+ # Tool execution mirrors gptel's `gptel--handle-tool-use': synchronous
139
+ # tools (Read, Edit, Glob, ...) run ONE AT A TIME in model-emitted
140
+ # order; asynchronous tools (Bash, Agent) are dispatched in line and
141
+ # run concurrently in the background, their results awaited afterwards
142
+ # in original call order.
143
+ # Tools a sub-agent must NOT see or call: it runs autonomously as a
144
+ # one-shot task inside the parent's tool round, so it cannot spawn
145
+ # further sub-agents (Agent), ask the user questions (Question), nor
146
+ # end in a plan/build handoff (PlanExit). TodoWrite is also parent-only:
147
+ # a sub-agent is a single delegated task — progress tracking belongs to
148
+ # the parent, and the sub-agent must never clobber the parent's list.
149
+ SUBAGENT_EXCLUDED_TOOLS = ("Agent", "Question", "PlanExit", "TodoWrite")
150
+
151
+ # ---- TUI preview limits -------------------------------------------------------
152
+ TOOL_RESULT_PREVIEW_LINES = 5 # max lines of a tool result shown in the TUI
153
+ TOOL_RESULT_PREVIEW_CHARS = 500 # max chars of that preview (long single lines)
154
+
155
+ # ---- default agent prompts -----------------------------------------------------
156
+ # Ported system prompts (opencode-style) for the main agent and sub-agents,
157
+ # bundled with the package (prompts/agent.md, prompts/subagent.md).
158
+ # Missing files are tolerated: callers fall back to no system prompt.
159
+ PROMPTS_DIR = Path(__file__).parent / "prompts"
160
+ DEFAULT_AGENT_PROMPT_FILE = PROMPTS_DIR / "agent.md"
161
+ DEFAULT_SUBAGENT_PROMPT_FILE = PROMPTS_DIR / "subagent.md"
162
+
163
+ # ---- configuration file ---------------------------------------------------------
164
+ CONFIG_DIR = (
165
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "python-agent-harness"
166
+ )
167
+ CONFIG_FILE = CONFIG_DIR / "config.json"
168
+
169
+ DEFAULT_LLM: dict = {
170
+ "base_url": "https://api.openai.com/v1",
171
+ "api_key": None,
172
+ "model": "gpt-5-mini",
173
+ "backend": "OpenAI-compatible",
174
+ "temperature": TEMPERATURE,
175
+ "max_tokens": MAX_TOKENS,
176
+ "timeout": 600.0,
177
+ "reasoning_effort": None,
178
+ "stream": True,
179
+ }
180
+
181
+ DEFAULT_PATHS: dict = {
182
+ "context_path": None,
183
+ "skill_path": None,
184
+ }
185
+
186
+ # Sub-agent LLM overrides: every key defaults to None, meaning "inherit
187
+ # the main LLM setting" (mirrors gptel-agent-harness-subagent-model /
188
+ # -backend). Only the keys the user actually sets differ from the main
189
+ # agent's LLM. ``profile`` references a named profile from the
190
+ # ``models`` section: its settings are applied on top of any explicit
191
+ # subagent_llm keys (profile wins), and unset keys still inherit the
192
+ # main LLM settings.
193
+ DEFAULT_SUBAGENT_LLM: dict = {
194
+ "profile": None,
195
+ "base_url": None,
196
+ "api_key": None,
197
+ "model": None,
198
+ "backend": None,
199
+ "temperature": None,
200
+ "max_tokens": None,
201
+ "timeout": None,
202
+ "reasoning_effort": None,
203
+ "stream": None,
204
+ }
205
+
206
+ CONFIG_TEMPLATE = """\
207
+ {{
208
+ "_comment": "python-agent-harness configuration. Location: {path}. Precedence: code defaults < this file < OPENAI_* / OPENAI_SUBAGENT_* environment variables.",
209
+ "llm": {{
210
+ "base_url": "https://api.deepseek.com/v1",
211
+ "model": "deepseek-chat",
212
+ "reasoning_effort": "medium",
213
+ "stream": true
214
+ }},
215
+ "models": {{
216
+ "_comment": "Named LLM profiles for /model switching. Each entry is a full set of LLM settings (base_url, api_key, model, etc.). Use /model in the TUI to switch at runtime.",
217
+ "deepseek": {{
218
+ "base_url": "https://api.deepseek.com/v1",
219
+ "model": "deepseek-chat"
220
+ }},
221
+ "openai": {{
222
+ "base_url": "https://api.openai.com/v1",
223
+ "model": "gpt-5-mini"
224
+ }}
225
+ }},
226
+ "subagent_llm": {{
227
+ "_comment": "Optional overrides for sub-agent (Agent tool) requests, e.g. a cheaper model. Every key is optional; unset keys inherit the main llm settings above. Set 'profile' to a name from the 'models' section to reuse a model profile (profile settings win over explicit keys below).",
228
+ "profile": null,
229
+ "base_url": null,
230
+ "api_key": null,
231
+ "model": null,
232
+ "temperature": null,
233
+ "max_tokens": null,
234
+ "timeout": null,
235
+ "reasoning_effort": null,
236
+ "stream": null
237
+ }},
238
+ "paths": {{
239
+ "_comment": "Optional overrides for context and skill directories. Absolute paths or ~ expansion supported.",
240
+ "context_path": null,
241
+ "skill_path": null
242
+ }},
243
+ "mcp": {{
244
+ "_comment": "Optional MCP servers (requires: pip install -e '.[mcp]'). Each server's tools become agent tools named mcp__<server>__<tool>. Transports: stdio (spawn command+args, pass through env var names), streamable-http / sse (connect to url, optional headers). 'parallel: true' marks read-only servers whose tools may run concurrently; default is serial. 'timeout' bounds connects, discovery and calls (seconds).",
245
+ "servers": {{
246
+ "example": {{
247
+ "_comment": "Example only — enabled: false keeps it from connecting. Set enabled: true and adjust command/args.",
248
+ "transport": "stdio",
249
+ "command": "npx",
250
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
251
+ "env": [],
252
+ "parallel": false,
253
+ "timeout": null,
254
+ "enabled": false
255
+ }}
256
+ }}
257
+ }}
258
+ }}
259
+ """
260
+
261
+ _ENV_OVERRIDES = {
262
+ "base_url": "OPENAI_BASE_URL",
263
+ "api_key": "OPENAI_API_KEY",
264
+ "model": "OPENAI_MODEL",
265
+ "backend": "OPENAI_BACKEND",
266
+ }
267
+
268
+ _SUBAGENT_ENV_OVERRIDES = {
269
+ "base_url": "OPENAI_SUBAGENT_BASE_URL",
270
+ "api_key": "OPENAI_SUBAGENT_API_KEY",
271
+ "model": "OPENAI_SUBAGENT_MODEL",
272
+ "backend": "OPENAI_SUBAGENT_BACKEND",
273
+ }
274
+
275
+
276
+ def _config_path(path: str | os.PathLike | None = None) -> Path:
277
+ """Resolve the config file path: explicit arg > $PYTHON_AGENT_HARNESS_CONFIG > default."""
278
+ if path:
279
+ return Path(path).expanduser()
280
+ env = os.environ.get("PYTHON_AGENT_HARNESS_CONFIG")
281
+ if env:
282
+ return Path(env).expanduser()
283
+ return CONFIG_FILE
284
+
285
+
286
+ def _read_config(path: str | os.PathLike | None = None) -> dict:
287
+ """Read and parse the config file; ``{}`` when it does not exist.
288
+
289
+ Raises ValueError on unreadable/invalid JSON so config errors
290
+ surface at session start. Callers that tolerate a broken file
291
+ (e.g. `load_paths_config`) catch it and fall back to defaults.
292
+ """
293
+ cfg_path = _config_path(path)
294
+ if not cfg_path.exists():
295
+ return {}
296
+ try:
297
+ with open(cfg_path, "rb") as f:
298
+ data = json.load(f)
299
+ except Exception as e: # noqa: BLE001
300
+ raise ValueError(f"cannot read config file {cfg_path}: {e}") from e
301
+ if not isinstance(data, dict):
302
+ raise ValueError(f"config file {cfg_path}: top level must be an object")
303
+ return data
304
+
305
+
306
+ def load_llm_config(path: str | os.PathLike | None = None) -> dict:
307
+ """Resolve LLM settings: code defaults < config file < environment.
308
+
309
+ The config file is JSON with an ``llm`` object (see `CONFIG_TEMPLATE`).
310
+ Environment variables still win if set, so existing setups keep working.
311
+ """
312
+ settings = dict(DEFAULT_LLM)
313
+ data = _read_config(path)
314
+ llm = data.get("llm") or {}
315
+ if not isinstance(llm, dict):
316
+ raise ValueError(f"config file {_config_path(path)}: llm must be an object")
317
+ for key in (
318
+ "base_url",
319
+ "api_key",
320
+ "model",
321
+ "backend",
322
+ "temperature",
323
+ "max_tokens",
324
+ "timeout",
325
+ "reasoning_effort",
326
+ "stream",
327
+ ):
328
+ if key in llm and llm[key] is not None:
329
+ settings[key] = llm[key]
330
+ for key, env in _ENV_OVERRIDES.items():
331
+ val = os.environ.get(env)
332
+ if val:
333
+ settings[key] = val
334
+ return settings
335
+
336
+
337
+ def load_subagent_llm_config(
338
+ path: str | os.PathLike | None = None,
339
+ main: dict | None = None,
340
+ ) -> dict:
341
+ """Resolve sub-agent LLM settings; unset keys inherit ``main``.
342
+
343
+ Mirrors gptel-agent-harness-subagent-model/-backend: sub-agents
344
+ (the Agent tool) use their own LLM when configured, otherwise the
345
+ main agent's. Precedence: ``main`` settings < config file
346
+ ``subagent_llm`` object < referenced ``models`` profile (when
347
+ ``subagent_llm.profile`` is set) < OPENAI_SUBAGENT_* environment
348
+ variables. A referenced profile's keys win over explicit
349
+ ``subagent_llm`` keys; keys the profile leaves unset still inherit
350
+ the main settings.
351
+
352
+ Returns a fully resolved settings dict (same keys as
353
+ `load_llm_config`) that callers can use to build a sub-agent
354
+ Client; when no override is set anywhere it equals ``main``.
355
+ """
356
+ main = dict(main) if main else dict(DEFAULT_LLM)
357
+ overrides = dict(DEFAULT_SUBAGENT_LLM)
358
+ data = _read_config(path)
359
+ sub = data.get("subagent_llm") or {}
360
+ if not isinstance(sub, dict):
361
+ raise ValueError(f"config file {_config_path(path)}: subagent_llm must be an object")
362
+ for key in DEFAULT_SUBAGENT_LLM:
363
+ if key in sub and sub[key] is not None:
364
+ overrides[key] = sub[key]
365
+ profile_name = overrides.get("profile")
366
+ if profile_name:
367
+ models = data.get("models") or {}
368
+ if not isinstance(models, dict):
369
+ raise ValueError(f"config file {_config_path(path)}: models must be an object")
370
+ profile = models.get(profile_name)
371
+ if not isinstance(profile, dict):
372
+ raise ValueError(
373
+ f"config file {_config_path(path)}: subagent_llm.profile references "
374
+ f"unknown models profile {profile_name!r}"
375
+ )
376
+ for key in (
377
+ "base_url",
378
+ "api_key",
379
+ "model",
380
+ "backend",
381
+ "temperature",
382
+ "max_tokens",
383
+ "timeout",
384
+ "reasoning_effort",
385
+ "stream",
386
+ ):
387
+ if key in profile and profile[key] is not None:
388
+ overrides[key] = profile[key]
389
+ for key, env in _SUBAGENT_ENV_OVERRIDES.items():
390
+ val = os.environ.get(env)
391
+ if val:
392
+ overrides[key] = val
393
+ overrides.pop("profile", None)
394
+ for key, val in overrides.items():
395
+ if val is not None:
396
+ main[key] = val
397
+ return main
398
+
399
+
400
+ def load_paths_config(path: str | os.PathLike | None = None) -> dict:
401
+ """Load paths settings from the config file.
402
+
403
+ Returns a dict with ``context_path`` and ``skill_path`` keys.
404
+ Values are expanded (~ → home) and resolved to absolute paths when
405
+ set; None means "use default discovery logic".
406
+ """
407
+ settings = dict(DEFAULT_PATHS)
408
+ try:
409
+ data = _read_config(path)
410
+ except ValueError:
411
+ return settings
412
+ paths = data.get("paths") or {}
413
+ if not isinstance(paths, dict):
414
+ return settings
415
+ for key in ("context_path", "skill_path"):
416
+ val = paths.get(key)
417
+ if isinstance(val, str) and val.strip():
418
+ settings[key] = os.path.abspath(os.path.expanduser(val.strip()))
419
+ return settings
420
+
421
+
422
+ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
423
+ """Load MCP server settings from the config file's ``mcp`` object.
424
+
425
+ Returns an ``MCPConfig`` (empty when the file has no ``mcp``
426
+ section or it has no servers). Malformed server entries raise
427
+ ValueError so config errors surface at session start. The optional
428
+ ``mcp`` SDK is only needed when servers are actually configured and
429
+ connected — reading the config never requires it.
430
+ """
431
+ from .mcp.config import MCPConfig
432
+
433
+ data = _read_config(path)
434
+ section = data.get("mcp") or {}
435
+ if not isinstance(section, dict):
436
+ raise ValueError(f"config file {_config_path(path)}: mcp must be an object")
437
+ return MCPConfig.from_dict(section.get("servers"))
438
+
439
+
440
+ def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]:
441
+ """Load named LLM profiles from the config file's ``models`` object.
442
+
443
+ Returns a dict mapping profile names to their LLM settings dicts.
444
+ Each profile is a partial set of DEFAULT_LLM keys (base_url, api_key,
445
+ model, etc.); unset keys inherit the main ``llm`` settings when the
446
+ profile is applied. An empty dict when the file has no ``models``
447
+ section or it is empty.
448
+ """
449
+ data = _read_config(path)
450
+ section = data.get("models") or {}
451
+ if not isinstance(section, dict):
452
+ raise ValueError(f"config file {_config_path(path)}: models must be an object")
453
+ profiles: dict[str, dict] = {}
454
+ for name, val in section.items():
455
+ if name.startswith("_"):
456
+ continue
457
+ if not isinstance(val, dict):
458
+ raise ValueError(f"config file {_config_path(path)}: models.{name} must be an object")
459
+ profiles[name] = val
460
+ return profiles
461
+
462
+
463
+ def mask_secret(value: str | None) -> str:
464
+ return "****" if value else "(unset)"