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