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,273 @@
1
+ """CLI entry points: interactive TUI session and configuration.
2
+
3
+ Commands:
4
+ run [project] interactive TUI agent session (default)
5
+ config [--init] show effective LLM config / write a template file
6
+
7
+ Custom commands (prompts/commands/*.md) — like init, review,
8
+ sessions, restore and summary/explain — are TUI slash commands only;
9
+ they are NOT registered as CLI subcommands.
10
+
11
+ Configuration (LLM etc.) is read from a JSON file, by default
12
+ ~/.config/python-agent-harness/config.json; see `config --init`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+
21
+ from . import config
22
+ from .client import Client
23
+ from .session import Session
24
+ from .tools import default_registry
25
+
26
+
27
+ def make_session(
28
+ project_dir: str,
29
+ config_path: str | None = None,
30
+ model: str | None = None,
31
+ stream: bool | None = None,
32
+ ) -> Session:
33
+ """Create a Session from config file + env (no env required).
34
+
35
+ The system prompt defaults to the ported main-agent prompt
36
+ (config.DEFAULT_AGENT_PROMPT_FILE); the sub-agent prompt always
37
+ defaults to config.DEFAULT_SUBAGENT_PROMPT_FILE. Either default
38
+ falls back to no system prompt if its file is unavailable.
39
+ """
40
+ settings = config.load_llm_config(config_path)
41
+ paths = config.load_paths_config(config_path)
42
+ mcp_config = config.load_mcp_config(config_path)
43
+ model = model or settings["model"]
44
+ # resolve sub-agent overrides against the EFFECTIVE main settings
45
+ # (so a CLI/caller model override is inherited too when the
46
+ # subagent_llm model is unset)
47
+ settings["model"] = model
48
+ subagent_settings = config.load_subagent_llm_config(config_path, main=settings)
49
+ client = Client(
50
+ base_url=settings["base_url"],
51
+ api_key=settings["api_key"],
52
+ model=model,
53
+ timeout=settings["timeout"],
54
+ config_path=config_path,
55
+ )
56
+ # A separate client for sub-agent requests only when a different
57
+ # LLM is configured (mirrors gptel-agent-harness-subagent-model /
58
+ # -backend); otherwise the sub-agent shares the main client.
59
+ subagent_client = None
60
+ if any(
61
+ subagent_settings[k] != settings[k] for k in ("base_url", "api_key", "model", "timeout")
62
+ ):
63
+ subagent_client = Client(
64
+ base_url=subagent_settings["base_url"],
65
+ api_key=subagent_settings["api_key"],
66
+ model=subagent_settings["model"],
67
+ timeout=subagent_settings["timeout"],
68
+ config_path=config_path,
69
+ )
70
+ # keep every request of this session (main + sub-agents) in the
71
+ # same LLM log file — the TUI advertises the main client's log
72
+ # path, and a separate sub-agent log would fragment debugging
73
+ subagent_client.log_path = client.log_path
74
+ from .prompts import assemble_agent_prompt, load_agent_prompt
75
+ from .session import find_skill_dir
76
+
77
+ abs_project = os.path.abspath(project_dir)
78
+ skill_dir = find_skill_dir(abs_project, paths.get("skill_path"))
79
+ system_prompt = assemble_agent_prompt(
80
+ abs_project,
81
+ load_agent_prompt(config.DEFAULT_AGENT_PROMPT_FILE, skill_dir=skill_dir),
82
+ context_path=paths.get("context_path"),
83
+ )
84
+ # sub-agents get ONLY their own system prompt — no parent project
85
+ # context and no task-completion rules injected
86
+ subagent_system_prompt = load_agent_prompt(
87
+ config.DEFAULT_SUBAGENT_PROMPT_FILE, skill_dir=skill_dir
88
+ )
89
+ # Resolve the effective stream once: the CLI --no-stream flag wins
90
+ # over the config file for the whole session, sub-agents included
91
+ # (same precedence as the main agent's stream).
92
+ effective_stream = settings["stream"] if stream is None else stream
93
+ model_profiles = config.load_models_config(config_path)
94
+ # Base settings for /model switching: the main llm settings as
95
+ # resolved at session start (incl. the CLI --model/--no-stream
96
+ # overrides above), so a profile's unset keys inherit these
97
+ # instead of values drifted by earlier switches.
98
+ llm_settings = dict(settings)
99
+ llm_settings["stream"] = effective_stream
100
+ return Session(
101
+ project_dir=abs_project,
102
+ client=client,
103
+ model=model,
104
+ backend=settings["backend"],
105
+ system_prompt=system_prompt,
106
+ subagent_system_prompt=subagent_system_prompt,
107
+ temperature=settings["temperature"],
108
+ max_tokens=settings["max_tokens"],
109
+ reasoning_effort=settings["reasoning_effort"],
110
+ stream=effective_stream,
111
+ subagent_client=subagent_client,
112
+ subagent_temperature=subagent_settings["temperature"],
113
+ subagent_max_tokens=subagent_settings["max_tokens"],
114
+ subagent_reasoning_effort=subagent_settings["reasoning_effort"],
115
+ subagent_stream=(effective_stream if stream is not None else subagent_settings["stream"]),
116
+ registry=default_registry(),
117
+ context_path=paths.get("context_path"),
118
+ skill_path=paths.get("skill_path"),
119
+ mcp=mcp_config,
120
+ model_profiles=model_profiles,
121
+ llm_settings=llm_settings,
122
+ config_path=config_path,
123
+ )
124
+
125
+
126
+ def make_session_with_mcp(
127
+ project_dir: str,
128
+ config_path: str | None = None,
129
+ model: str | None = None,
130
+ stream: bool | None = None,
131
+ ) -> Session:
132
+ """Create a Session and connect its configured MCP servers.
133
+
134
+ Wraps ``make_session``: the session's MCP servers are connected and
135
+ their tools registered before the session is returned (discovery
136
+ happens once, at session start). Per-server failures are printed
137
+ to stderr and never prevent the session from running — the agent
138
+ keeps working with the built-in tools.
139
+ """
140
+ session = make_session(project_dir, config_path=config_path, model=model, stream=stream)
141
+ failures = session.connect_mcp()
142
+ for server, err in failures:
143
+ print(f"python-agent-harness: [{server}] {err}", file=sys.stderr)
144
+ return session
145
+
146
+
147
+ def cmd_run(args: argparse.Namespace) -> int:
148
+ from .tui import Tui
149
+
150
+ project_dir = getattr(args, "project", None) or os.getcwd()
151
+ session = make_session_with_mcp(
152
+ project_dir,
153
+ config_path=args.config,
154
+ stream=False if getattr(args, "no_stream", False) else None,
155
+ )
156
+ Tui(session).run()
157
+ session.close()
158
+ return 0
159
+
160
+
161
+ def cmd_config(args: argparse.Namespace) -> int:
162
+ path = config._config_path(args.path)
163
+ if args.init:
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ if path.exists() and not args.force:
166
+ print(f"config already exists: {path} (use --force to overwrite)")
167
+ return 1
168
+ template = config.CONFIG_TEMPLATE.format(path=path)
169
+ path.write_text(template, encoding="utf-8")
170
+ print(f"wrote config template: {path}")
171
+ return 0
172
+ settings = config.load_llm_config(args.path)
173
+ subagent_settings = config.load_subagent_llm_config(args.path, main=settings)
174
+ paths = config.load_paths_config(args.path)
175
+ mcp_config = config.load_mcp_config(args.path)
176
+ print(f"config file: {path}")
177
+ if not path.exists():
178
+ print("(file does not exist yet — run `python-agent-harness config --init` to create it)")
179
+ for key in ("base_url", "model", "backend"):
180
+ print(f"{key}: {settings[key]}")
181
+ print(f"api_key: {config.mask_secret(settings['api_key'])}")
182
+ print(f"temperature: {settings['temperature']}")
183
+ print(f"max_tokens: {settings['max_tokens']}")
184
+ print(f"reasoning_effort: {settings['reasoning_effort']}")
185
+ print(f"stream: {settings['stream']}")
186
+ print(f"timeout: {settings['timeout']}")
187
+ print(f"context_path: {paths['context_path'] or '(default: <project>/contexts)'}")
188
+ print(f"skill_path: {paths['skill_path'] or '(default: <project>/skills)'}")
189
+ if mcp_config.servers:
190
+ for name, server in mcp_config.servers.items():
191
+ status = "enabled" if server.enabled else "disabled"
192
+ target = (
193
+ f"command={server.command} {' '.join(server.args)}"
194
+ if server.transport == "stdio"
195
+ else f"url={server.url}"
196
+ )
197
+ print(
198
+ f"mcp server {name}: {status}, transport={server.transport}, "
199
+ f"{target}, parallel={server.parallel}"
200
+ )
201
+ else:
202
+ print('mcp: (none configured — add an "mcp" section to the config file)')
203
+ print(
204
+ "subagent_llm: (inherits main)"
205
+ if subagent_settings == settings
206
+ else f"subagent_llm: model={subagent_settings['model']} "
207
+ f"base_url={subagent_settings['base_url']} "
208
+ f"api_key={config.mask_secret(subagent_settings['api_key'])} "
209
+ f"temperature={subagent_settings['temperature']} "
210
+ f"max_tokens={subagent_settings['max_tokens']} "
211
+ f"reasoning_effort={subagent_settings['reasoning_effort']} "
212
+ f"stream={subagent_settings['stream']} timeout={subagent_settings['timeout']}"
213
+ )
214
+ # Show model profiles for /model command
215
+ model_profiles = config.load_models_config(args.path)
216
+ if model_profiles:
217
+ print("models:")
218
+ for name, profile in sorted(model_profiles.items()):
219
+ model_name = profile.get("model", "(inherited)")
220
+ base_url = profile.get("base_url", "(inherited)")
221
+ print(f" {name}: model={model_name}, base_url={base_url}")
222
+ else:
223
+ print("models: (none configured — add a 'models' section to use /model)")
224
+ return 0
225
+
226
+
227
+ def _add_config_arg(parser: argparse.ArgumentParser, suppress: bool = False) -> None:
228
+ parser.add_argument(
229
+ "--config",
230
+ metavar="PATH",
231
+ default=argparse.SUPPRESS if suppress else None,
232
+ help="path to config.json (default: ~/.config/python-agent-harness/config.json)",
233
+ )
234
+
235
+
236
+ def build_parser() -> argparse.ArgumentParser:
237
+ parser = argparse.ArgumentParser(
238
+ prog="python-agent-harness",
239
+ description="Python agent execution harness (gptel-agent-harness port)",
240
+ )
241
+ _add_config_arg(parser)
242
+ sub = parser.add_subparsers(dest="command")
243
+
244
+ p_run = sub.add_parser("run", help="interactive TUI agent session")
245
+ _add_config_arg(p_run, suppress=True)
246
+ p_run.add_argument(
247
+ "--no-stream",
248
+ action="store_true",
249
+ help="disable streaming (one-shot responses; overrides config file)",
250
+ )
251
+ p_run.add_argument("project", nargs="?", help="project directory (default: cwd)")
252
+
253
+ p_config = sub.add_parser("config", help="show effective LLM config or write a template file")
254
+ p_config.add_argument("--init", action="store_true", help="write a config template")
255
+ p_config.add_argument("--force", action="store_true", help="overwrite an existing file")
256
+ p_config.add_argument("--path", metavar="PATH", help="config file path")
257
+ p_config.set_defaults(func=cmd_config)
258
+ return parser
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ parser = build_parser()
263
+ args = parser.parse_args(argv)
264
+ if args.command in (None, "run"):
265
+ return cmd_run(args)
266
+ if args.command == "config":
267
+ return cmd_config(args)
268
+ parser.print_help()
269
+ return 1
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main())