python-agent-harness 1.5.5.0__tar.gz → 1.5.5.2__tar.gz

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 (94) hide show
  1. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/PKG-INFO +30 -2
  2. python_agent_harness-1.5.5.0/python_agent_harness.egg-info/PKG-INFO → python_agent_harness-1.5.5.2/README.md +18 -23
  3. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/pyproject.toml +19 -6
  4. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/agent.py +17 -3
  6. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/cli.py +21 -7
  7. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/client.py +15 -2
  8. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/commands.py +0 -5
  9. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/config.py +18 -0
  10. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/persistence.py +1 -7
  11. python_agent_harness-1.5.5.2/python_agent_harness/prompts/agents/reviewer.md +12 -0
  12. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts.py +57 -2
  13. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/session.py +45 -2
  14. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/commands.py +80 -3
  15. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/core.py +15 -2
  16. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/input.py +1 -0
  17. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/render.py +52 -17
  18. python_agent_harness-1.5.5.0/README.md → python_agent_harness-1.5.5.2/python_agent_harness.egg-info/PKG-INFO +51 -0
  19. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/SOURCES.txt +2 -0
  20. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_cli.py +1 -0
  21. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_commands.py +0 -1
  22. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_concurrent_subagents.py +2 -0
  23. python_agent_harness-1.5.5.2/tests/test_custom_agents.py +363 -0
  24. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_invariants.py +1 -0
  25. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_mcp.py +1 -0
  26. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_persistence.py +6 -9
  27. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_planmode.py +1 -0
  28. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_scenarios.py +1 -0
  29. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_session.py +2 -1
  30. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_subagent.py +1 -0
  31. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_subagent_isolation.py +57 -0
  32. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_todos_scope.py +6 -0
  33. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_tools_misc.py +4 -0
  34. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/LICENSE +0 -0
  35. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/__main__.py +0 -0
  36. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/context_manager.py +0 -0
  37. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/diffrender.py +0 -0
  38. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/__init__.py +0 -0
  39. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/client.py +0 -0
  40. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/config.py +0 -0
  41. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/mcp/manager.py +0 -0
  42. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/models.py +0 -0
  43. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/planmode.py +0 -0
  44. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/agent.md +0 -0
  45. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/build-switch.md +0 -0
  46. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/commands/explain.md +0 -0
  47. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/compact.md +0 -0
  48. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/initialize.md +0 -0
  49. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/plan-mode.md +0 -0
  50. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/plan.md +0 -0
  51. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/review.md +0 -0
  52. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/subagent.md +0 -0
  53. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/summary.md +0 -0
  54. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  55. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/prompts/title.md +0 -0
  56. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/subagent.py +0 -0
  57. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/token_estimator.py +0 -0
  58. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tool_runner.py +0 -0
  59. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/__init__.py +0 -0
  60. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/agent_tool.py +0 -0
  61. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/base.py +0 -0
  62. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/bash.py +0 -0
  63. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/diffapply.py +0 -0
  64. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/edit.py +0 -0
  65. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/edit_mac.py +0 -0
  66. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/filesystem.py +0 -0
  67. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/glob.py +0 -0
  68. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/glob_mac.py +0 -0
  69. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/grep.py +0 -0
  70. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/grep_mac.py +0 -0
  71. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/insert.py +0 -0
  72. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/mcp.py +0 -0
  73. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/mkdir.py +0 -0
  74. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/planexit.py +0 -0
  75. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/question.py +0 -0
  76. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/read.py +0 -0
  77. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/skill.py +0 -0
  78. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/todo.py +0 -0
  79. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tools/write.py +0 -0
  80. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness/tui/__init__.py +0 -0
  81. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  82. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/entry_points.txt +0 -0
  83. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/requires.txt +0 -0
  84. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/python_agent_harness.egg-info/top_level.txt +0 -0
  85. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/setup.cfg +0 -0
  86. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_agents_md.py +0 -0
  87. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_client.py +0 -0
  88. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_config.py +0 -0
  89. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_context_rules.py +0 -0
  90. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_diffrender.py +0 -0
  91. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_filesystem.py +0 -0
  92. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_models.py +0 -0
  93. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_prompts.py +0 -0
  94. {python_agent_harness-1.5.5.0 → python_agent_harness-1.5.5.2}/tests/test_token_estimator.py +0 -0
@@ -1,9 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.0
3
+ Version: 1.5.5.2
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
- Author: huming
5
+ Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/beacoder/python-agent-harness
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
7
17
  Requires-Python: >=3.11
8
18
  Description-Content-Type: text/markdown
9
19
  License-File: LICENSE
@@ -31,6 +41,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
31
41
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
32
42
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
33
43
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
44
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
34
45
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
46
 
36
47
  </div>
@@ -99,6 +110,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
99
110
  - **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
100
111
  - **MCP support** — optional MCP integration through the `[mcp]` extra. MCP tools become ordinary agent tools such as `mcp__<server>__<tool>`. Supports `stdio`, `streamable-http`, and `sse` transports.
101
112
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, and other commands, plus custom commands loaded from `prompts/commands/*.md`.
113
+ - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent prompt files live in `prompts/agents/*.md`. Use `default_agent` in the config file to start sessions with a specific agent.
102
114
 
103
115
  ## Inspired by opencode
104
116
 
@@ -160,6 +172,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
160
172
  "reasoning_effort": null,
161
173
  "stream": null
162
174
  },
175
+ "default_agent": null,
163
176
  "paths": {
164
177
  "context_path": null,
165
178
  "skill_path": null
@@ -190,6 +203,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
190
203
  - **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
191
204
  - **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
192
205
  - **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
206
+ - **`default_agent`** — name of the agent to use at session start (instead of the built-in `agent.md`). The agent must exist as a `.md` file in the `prompts/agents/` directory. When unset or `null`, the built-in default agent is used. Use `/agent default` in the TUI to switch back to the built-in at any time.
193
207
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
194
208
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
195
209
  - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
@@ -219,10 +233,24 @@ Launches the interactive TUI agent. If `project-dir` is omitted, the current dir
219
233
  | `/restore [path\|title\|--latest\|latest]` | Restore a session; title matching uses substring search |
220
234
  | `/clear` | Start a fresh conversation |
221
235
  | `/model [name]` | Switch LLM profiles; `default` restores the session's original model |
236
+ | `/agent [name]` | Switch agent system prompt; `default` restores the built-in `agent.md` |
222
237
  | `/exit` | Quit |
223
238
 
224
239
  Custom commands from `prompts/commands/*.md` are registered as slash commands as well (TUI only).
225
240
 
241
+ ### Custom agents
242
+
243
+ Agent prompt files are markdown files (`.md`) placed in the `prompts/agents/` directory. Each file becomes a switchable agent profile available via the `/agent` TUI command.
244
+
245
+ #### Commands vs Agents
246
+
247
+ | | Commands (`/review`) | Agents (`/agent reviewer`) |
248
+ |---|---|---|
249
+ | **Scope** | One-shot (prompt resets after run) | Persistent (stays until next `/agent`) |
250
+ | **Kickoff** | Hardcoded kickoff message | User types their own prompt |
251
+ | **Tools** | Can restrict (`allow_planexit=False`) | All tools |
252
+ | **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
253
+
226
254
  ## Project layout
227
255
 
228
256
  ```text
@@ -1,26 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: python-agent-harness
3
- Version: 1.5.5.0
4
- Summary: A lightweight, hackable mini-OpenCode written in Python.
5
- Author: huming
6
- License-Expression: MIT
7
- Requires-Python: >=3.11
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: rich>=13.0
11
- Requires-Dist: httpx>=0.27
12
- Requires-Dist: prompt_toolkit>=3.0
13
- Provides-Extra: mcp
14
- Requires-Dist: mcp<3,>=2.0; extra == "mcp"
15
- Provides-Extra: dev
16
- Requires-Dist: ruff>=0.6; extra == "dev"
17
- Requires-Dist: pyright>=1.1.380; extra == "dev"
18
- Requires-Dist: build>=1.2; extra == "dev"
19
- Requires-Dist: coverage[toml]>=7.0; extra == "dev"
20
- Requires-Dist: pip-audit>=2.7; extra == "dev"
21
- Requires-Dist: mcp<3,>=2.0; extra == "dev"
22
- Dynamic: license-file
23
-
24
1
  <div align="center">
25
2
 
26
3
  # python-agent-harness
@@ -31,6 +8,7 @@ FSM-driven execution · OpenAI-compatible · built for daily use and easy custom
31
8
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
32
9
  [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
33
10
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
11
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/python-agent-harness?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/python-agent-harness)
34
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
13
 
36
14
  </div>
@@ -99,6 +77,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
99
77
  - **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
100
78
  - **MCP support** — optional MCP integration through the `[mcp]` extra. MCP tools become ordinary agent tools such as `mcp__<server>__<tool>`. Supports `stdio`, `streamable-http`, and `sse` transports.
101
79
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, and other commands, plus custom commands loaded from `prompts/commands/*.md`.
80
+ - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent prompt files live in `prompts/agents/*.md`. Use `default_agent` in the config file to start sessions with a specific agent.
102
81
 
103
82
  ## Inspired by opencode
104
83
 
@@ -160,6 +139,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
160
139
  "reasoning_effort": null,
161
140
  "stream": null
162
141
  },
142
+ "default_agent": null,
163
143
  "paths": {
164
144
  "context_path": null,
165
145
  "skill_path": null
@@ -190,6 +170,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
190
170
  - **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
191
171
  - **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
192
172
  - **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
173
+ - **`default_agent`** — name of the agent to use at session start (instead of the built-in `agent.md`). The agent must exist as a `.md` file in the `prompts/agents/` directory. When unset or `null`, the built-in default agent is used. Use `/agent default` in the TUI to switch back to the built-in at any time.
193
174
  - **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
194
175
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
195
176
  - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
@@ -219,10 +200,24 @@ Launches the interactive TUI agent. If `project-dir` is omitted, the current dir
219
200
  | `/restore [path\|title\|--latest\|latest]` | Restore a session; title matching uses substring search |
220
201
  | `/clear` | Start a fresh conversation |
221
202
  | `/model [name]` | Switch LLM profiles; `default` restores the session's original model |
203
+ | `/agent [name]` | Switch agent system prompt; `default` restores the built-in `agent.md` |
222
204
  | `/exit` | Quit |
223
205
 
224
206
  Custom commands from `prompts/commands/*.md` are registered as slash commands as well (TUI only).
225
207
 
208
+ ### Custom agents
209
+
210
+ Agent prompt files are markdown files (`.md`) placed in the `prompts/agents/` directory. Each file becomes a switchable agent profile available via the `/agent` TUI command.
211
+
212
+ #### Commands vs Agents
213
+
214
+ | | Commands (`/review`) | Agents (`/agent reviewer`) |
215
+ |---|---|---|
216
+ | **Scope** | One-shot (prompt resets after run) | Persistent (stays until next `/agent`) |
217
+ | **Kickoff** | Hardcoded kickoff message | User types their own prompt |
218
+ | **Tools** | Can restrict (`allow_planexit=False`) | All tools |
219
+ | **Ctrl-C** | Restores to default prompt | Stays on the custom agent |
220
+
226
221
  ## Project layout
227
222
 
228
223
  ```text
@@ -4,21 +4,34 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.0"
7
+ version = "1.5.5.2"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
+ authors = [
10
+ {name = "Huming Chen", email = "chenhuming@gmail.com"}
11
+ ]
12
+ license = "MIT"
9
13
  readme = "README.md"
10
14
  requires-python = ">=3.11"
11
- authors = [
12
- { name = "huming" }
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14"
13
25
  ]
14
- license = 'MIT'
15
- license-files = ["LICENSE"]
16
26
  dependencies = [
17
27
  "rich>=13.0",
18
28
  "httpx>=0.27",
19
29
  "prompt_toolkit>=3.0",
20
30
  ]
21
31
 
32
+ [project.urls]
33
+ Homepage = "https://github.com/beacoder/python-agent-harness"
34
+
22
35
  [project.optional-dependencies]
23
36
  # MCP (Model Context Protocol) client support: exposes MCP server tools to
24
37
  # the agent as namespaced mcp__<server>__<tool> tools. Install with:
@@ -49,7 +62,7 @@ python-agent-harness = "python_agent_harness.cli:main"
49
62
  include = ["python_agent_harness*"]
50
63
 
51
64
  [tool.setuptools.package-data]
52
- python_agent_harness = ["prompts/*.md", "prompts/commands/*.md"]
65
+ python_agent_harness = ["prompts/*.md", "prompts/commands/*.md", "prompts/agents/*.md"]
53
66
 
54
67
  [tool.ruff]
55
68
  line-length = 100
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.5.0"
8
+ __version__ = "1.5.5.2"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -493,7 +493,10 @@ class AgentLoop:
493
493
  return # cancelled (Ctrl-C), not an error
494
494
  self.error = f"Error: {e}"
495
495
  self.info["error"] = self.error
496
- session.notify("error")
496
+ # the detail travels with the notification so the TUI can
497
+ # show WHAT failed (e.g. "no quota") in the status bar
498
+ # instead of a bare red "error"
499
+ session.notify("error", self.error)
497
500
  return
498
501
 
499
502
  if self._is_cancelled():
@@ -634,14 +637,25 @@ def run_agent_loop(
634
637
 
635
638
  ``client`` (when given) overrides the session's client for this
636
639
  run — the per-invocation sub-agent clone."""
637
- return AgentLoop(
640
+ loop = AgentLoop(
638
641
  session,
639
642
  messages=messages,
640
643
  top_level=top_level,
641
644
  system=system,
642
645
  max_rounds=max_rounds,
643
646
  client=client,
644
- ).run()
647
+ )
648
+ result = loop.run()
649
+ # Notify run_done only for a successfully completed top-level run:
650
+ # - top_level: sub-agents share the parent's session (notify_fn
651
+ # included) and must not mark the parent run finished while it is
652
+ # still executing tools
653
+ # - DONE only: ERRS already notifies "error" with the detail, and
654
+ # ABRT is cancellation/supersession (TUI sets that status). DONE
655
+ # also implies not cancelled — the FSM routes cancelled to ABRT.
656
+ if loop.top_level and loop.state == AgentLoop.DONE:
657
+ session.notify("run_done")
658
+ return result
645
659
 
646
660
 
647
661
  class Supervisor:
@@ -20,6 +20,7 @@ import sys
20
20
 
21
21
  from . import config
22
22
  from .client import Client
23
+ from .prompts import RESERVED_AGENT_NAME
23
24
  from .session import Session
24
25
  from .tools import default_registry
25
26
 
@@ -91,13 +92,14 @@ def make_session(
91
92
  # (same precedence as the main agent's stream).
92
93
  effective_stream = settings["stream"] if stream is None else stream
93
94
  model_profiles = config.load_models_config(config_path)
95
+ default_agent = config.load_default_agent(config_path)
94
96
  # Base settings for /model switching: the main llm settings as
95
97
  # resolved at session start (incl. the CLI --model/--no-stream
96
98
  # overrides above), so a profile's unset keys inherit these
97
99
  # instead of values drifted by earlier switches.
98
100
  llm_settings = dict(settings)
99
101
  llm_settings["stream"] = effective_stream
100
- return Session(
102
+ session = Session(
101
103
  project_dir=abs_project,
102
104
  client=client,
103
105
  model=model,
@@ -117,9 +119,18 @@ def make_session(
117
119
  skill_path=paths.get("skill_path"),
118
120
  mcp=mcp_config,
119
121
  model_profiles=model_profiles,
122
+ default_agent=default_agent,
120
123
  llm_settings=llm_settings,
121
124
  config_path=config_path,
122
125
  )
126
+ if default_agent and default_agent != RESERVED_AGENT_NAME:
127
+ ok, msg = session.switch_agent(default_agent)
128
+ if not ok:
129
+ # A typo'd/missing default_agent must not silently start the
130
+ # session with the built-in prompt — record the config error
131
+ # so the TUI can surface it (see Tui.run).
132
+ session.startup_warnings.append(f"default_agent: {msg}")
133
+ return session
123
134
 
124
135
 
125
136
  def make_session_with_mcp(
@@ -132,14 +143,14 @@ def make_session_with_mcp(
132
143
 
133
144
  Wraps ``make_session``: the session's MCP servers are connected and
134
145
  their tools registered before the session is returned (discovery
135
- happens once, at session start). Per-server failures are printed
136
- to stderr and never prevent the session from running the agent
137
- keeps working with the built-in tools.
146
+ happens once, at session start). Per-server failures are recorded
147
+ as startup warnings (rendered by the TUI banner) and never prevent
148
+ the session from running — the agent keeps working with the
149
+ built-in tools.
138
150
  """
139
151
  session = make_session(project_dir, config_path=config_path, model=model, stream=stream)
140
- failures = session.connect_mcp()
141
- for server, err in failures:
142
- print(f"python-agent-harness: [{server}] {err}", file=sys.stderr)
152
+ for server, err in session.connect_mcp():
153
+ session.startup_warnings.append(f"MCP [{server}]: {err}")
143
154
  return session
144
155
 
145
156
 
@@ -228,6 +239,9 @@ def cmd_config(args: argparse.Namespace) -> int:
228
239
  print(f" {pattern}: {size}")
229
240
  else:
230
241
  print("context_windows: (none configured — built-in table in config.py applies)")
242
+ # Show default agent
243
+ default_agent = config.load_default_agent(args.path)
244
+ print(f"default_agent: {default_agent or '(default: agent.md)'}")
231
245
  return 0
232
246
 
233
247
 
@@ -36,6 +36,14 @@ class ApiError(Exception):
36
36
  """Raised when the API call itself fails (network/HTTP)."""
37
37
 
38
38
 
39
+ # Grace between shutdown(SHUT_RDWR) and close() of an in-flight socket
40
+ # on macOS (see _shutdown_and_close): 50ms proved too tight under load
41
+ # (the woken thread can still be parked in select() when the fd is
42
+ # released, losing the wakeup). 200ms keeps Ctrl-C responsive while
43
+ # making the fd-reuse race vanishingly unlikely.
44
+ _ABORT_SHUTDOWN_GRACE_S = 0.2
45
+
46
+
39
47
  class RetryableApiError(ApiError):
40
48
  """A transient failure (rate limit, server error) safe to retry.
41
49
 
@@ -865,12 +873,17 @@ def _shutdown_and_close(sock: _socket.socket) -> None:
865
873
  settimeout for the read timeout), so a blocked recv parks in
866
874
  select(); closing the fd before that select has processed the
867
875
  shutdown wakeup can lose the wakeup and leave the read parked
868
- forever. Give the woken thread a moment to observe the EOF first.
876
+ forever. Worse, ``abort()`` swaps in a fresh httpx client right
877
+ after, so a new socket could reuse the fd number before the
878
+ reader observed the EOF. The grace sleep delays the fd release
879
+ so the woken thread can observe the EOF on the old fd first.
869
880
  """
870
881
  with contextlib.suppress(OSError):
871
882
  sock.shutdown(_socket.SHUT_RDWR)
872
883
  if sys.platform == "darwin":
873
- time.sleep(0.05)
884
+ # macOS only; on Linux shutdown() alone reliably wakes the
885
+ # blocked recv (measured: <200ms unblock, no sleeps needed).
886
+ time.sleep(_ABORT_SHUTDOWN_GRACE_S)
874
887
  with contextlib.suppress(OSError):
875
888
  sock.close()
876
889
 
@@ -63,7 +63,6 @@ class SessionCommand:
63
63
  name: str,
64
64
  prompt_file: str,
65
65
  kickoff: str,
66
- buffer_name: str,
67
66
  status: str,
68
67
  validate_dir: bool = False,
69
68
  allow_planexit: bool = True,
@@ -71,7 +70,6 @@ class SessionCommand:
71
70
  self.name = name
72
71
  self.prompt_file = prompt_file
73
72
  self.kickoff = kickoff
74
- self.buffer_name = buffer_name
75
73
  self.status = status
76
74
  self.validate_dir = validate_dir
77
75
  self.allow_planexit = allow_planexit
@@ -99,7 +97,6 @@ def initialize_command() -> SessionCommand:
99
97
  name="initialize",
100
98
  prompt_file="initialize.md",
101
99
  kickoff="Analyze the repository at ${path} and create/update AGENTS.md.\n",
102
- buffer_name="*gptel-agent-init:*",
103
100
  status=" Initializing...",
104
101
  validate_dir=True,
105
102
  allow_planexit=False,
@@ -111,7 +108,6 @@ def review_command() -> SessionCommand:
111
108
  name="review",
112
109
  prompt_file="review.md",
113
110
  kickoff="Review the requested code changes.",
114
- buffer_name="*gptel-agent-review*",
115
111
  status=" Reviewing...",
116
112
  allow_planexit=False,
117
113
  )
@@ -161,7 +157,6 @@ def load_custom_commands() -> list[SessionCommand]:
161
157
  name=name,
162
158
  prompt_file=str(f.relative_to(PROMPTS_DIR)),
163
159
  kickoff="Proceed with the task described in your instructions.\n",
164
- buffer_name=f"*gptel-agent-{name}*",
165
160
  status=f" Running {name}...",
166
161
  )
167
162
  )
@@ -239,6 +239,7 @@ CONFIG_TEMPLATE = """\
239
239
  "reasoning_effort": null,
240
240
  "stream": null
241
241
  }},
242
+ "default_agent": null,
242
243
  "paths": {{
243
244
  "_comment": "Optional overrides for context and skill directories. Absolute paths or ~ expansion supported.",
244
245
  "context_path": null,
@@ -458,6 +459,23 @@ def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]
458
459
  return profiles
459
460
 
460
461
 
462
+ def load_default_agent(path: str | os.PathLike | None = None) -> str | None:
463
+ """Load the default agent name from the config file's ``default_agent`` key.
464
+
465
+ Returns the agent name (str) when set, or None when unset (meaning
466
+ use the built-in ``default`` agent, i.e. ``prompts/agent.md``).
467
+ """
468
+ data = _read_config(path)
469
+ val = data.get("default_agent")
470
+ if val is None:
471
+ return None
472
+ if not isinstance(val, str) or not val.strip():
473
+ raise ValueError(
474
+ f"config file {_config_path(path)}: default_agent must be a non-empty string"
475
+ )
476
+ return val.strip()
477
+
478
+
461
479
  def mask_secret(value: str | None) -> str:
462
480
  return "****" if value else "(unset)"
463
481
 
@@ -170,18 +170,12 @@ class SessionPersistence:
170
170
  lines = [";; Local Variables:"]
171
171
  pairs = [
172
172
  ("python-agent-harness--project-dir", self.project_dir),
173
- ("gptel-model", self.model),
174
- ("gptel-system-prompt", self.system_prompt),
175
- ("gptel-temperature", self.temperature),
176
- ("gptel-max-tokens", self.max_tokens),
173
+ ("python-agent-harness--model", self.model),
177
174
  ]
178
175
  for name, value in pairs:
179
176
  if value is None:
180
177
  continue
181
178
  lines.append(f";; {name}: {value!r}")
182
- if self.tool_names:
183
- names = " ".join(f'"{n}"' for n in self.tool_names)
184
- lines.append(f";; gptel--tool-names: ({names})")
185
179
  if self.round_times:
186
180
  stamps = " ".join(repr(float(t)) for t in self.round_times)
187
181
  lines.append(f";; python-agent-harness--round-times: {stamps}")
@@ -0,0 +1,12 @@
1
+ # Role and Behavior
2
+ You are a code reviewer agent. Your job is to review code changes
3
+ thoroughly and provide constructive feedback.
4
+
5
+ Focus on:
6
+ - Correctness and potential bugs
7
+ - Code style and readability
8
+ - Performance implications
9
+ - Security concerns
10
+ - Test coverage
11
+
12
+ Always provide actionable suggestions with specific file and line references.
@@ -1,7 +1,7 @@
1
1
  """Prompt loading and assembly.
2
2
 
3
3
  Ported from gptel-agent-harness.el: loads bundled prompt files
4
- (agent/subagent/commands), strips YAML frontmatter, discovers skills
4
+ (agent/subagent/commands/custom agents), strips YAML frontmatter, discovers skills
5
5
  for the {{SKILLS}} placeholder, assembles the effective system prompt
6
6
  from project context files + task-completion rules + agent prompt, and
7
7
  provides the compaction flow helpers (summarize the conversation with
@@ -22,10 +22,16 @@ from typing import Any
22
22
  from . import config
23
23
  from .models import Message
24
24
 
25
+ PROMPTS_DIR = Path(__file__).parent / "prompts"
26
+ AGENTS_DIR = PROMPTS_DIR / "agents"
27
+ # Reserved pseudo-name that restores the built-in agent (prompts/agent.md);
28
+ # custom agent files cannot claim it (see discover_agents).
29
+ RESERVED_AGENT_NAME = "default"
30
+
25
31
 
26
32
  def read_prompt_file(name: str) -> str:
27
33
  """Read a prompt file from the package prompts dir."""
28
- path = Path(__file__).parent / "prompts" / name
34
+ path = PROMPTS_DIR / name
29
35
  return path.read_text(encoding="utf-8")
30
36
 
31
37
 
@@ -496,3 +502,52 @@ def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
496
502
  Message(role="user", content=frame),
497
503
  *[Message(role="user", content=p) for p in prompts],
498
504
  ]
505
+
506
+
507
+ # ---- custom agent discovery -----------------------------------------------
508
+ # Agent prompt files are markdown files (``*.md``) in
509
+ # ``prompts/agents/``. Each file becomes a switchable agent profile
510
+ # available via the ``/agent`` TUI command. The file's stem (e.g.
511
+ # ``reviewer.md`` → ``reviewer``) is the agent name; an optional YAML
512
+ # frontmatter ``name:`` can override it. The built-in ``default``
513
+ # agent (``prompts/agent.md``) is always available and cannot be
514
+ # overridden by a file in this directory.
515
+
516
+
517
+ def _agent_name_from_file(path: Path) -> str:
518
+ """Derive agent name from a file: frontmatter ``name:`` if present,
519
+ otherwise the file stem (lowercased, non-alphanumerics → ``-``)."""
520
+ try:
521
+ text = path.read_text(encoding="utf-8", errors="replace")
522
+ except OSError:
523
+ return ""
524
+ m = _FRONTMATTER_RE.match(text)
525
+ if m:
526
+ for line in m.group(0).splitlines():
527
+ if line.startswith("name:"):
528
+ name = line[len("name:") :].strip()
529
+ if name:
530
+ return name
531
+ base = path.stem.lower()
532
+ base = re.sub(r"[^a-z0-9]+", "-", base)
533
+ return base.strip("-")
534
+
535
+
536
+ def discover_agents() -> dict[str, str]:
537
+ """Discover all custom agents in the ``prompts/agents/`` directory.
538
+
539
+ Returns a dict mapping agent name → absolute path to the prompt
540
+ file. Files without a valid name are skipped. The built-in
541
+ ``default`` agent is NOT included here — callers add it separately.
542
+ A file claiming the reserved name ``default`` (via stem or
543
+ frontmatter) is skipped: the built-in agent always wins.
544
+ """
545
+ if not AGENTS_DIR.is_dir():
546
+ return {}
547
+ agents: dict[str, str] = {}
548
+ for f in sorted(AGENTS_DIR.glob("*.md")):
549
+ name = _agent_name_from_file(f)
550
+ if not name or name == RESERVED_AGENT_NAME:
551
+ continue
552
+ agents[name] = str(f.resolve())
553
+ return agents
@@ -22,7 +22,7 @@ from .mcp.manager import MCPManager
22
22
  from .models import AgentMode
23
23
  from .persistence import SessionPersistence, escape_role_headers
24
24
  from .planmode import PlanMode
25
- from .prompts import index_skills
25
+ from .prompts import RESERVED_AGENT_NAME, discover_agents, index_skills
26
26
  from .subagent import run_subagent
27
27
  from .token_estimator import TokenCalibrator
28
28
  from .tools import Registry, ToolContext
@@ -89,6 +89,7 @@ class Session:
89
89
  mcp: MCPConfig | None = None,
90
90
  mcp_manager: MCPManager | None = None,
91
91
  model_profiles: dict[str, dict] | None = None,
92
+ default_agent: str | None = None,
92
93
  llm_settings: dict | None = None,
93
94
  config_path: str | None = None,
94
95
  ) -> None:
@@ -183,6 +184,17 @@ class Session:
183
184
  # model profile's unset keys inherit these values, so switching
184
185
  # between profiles never drifts settings from earlier switches.
185
186
  self.llm_settings: dict = dict(llm_settings) if llm_settings else {}
187
+ # Default agent name: when set, the session starts with this
188
+ # agent's prompt instead of the built-in default (agent.md).
189
+ # ``/agent default`` restores the built-in, not this setting.
190
+ self.default_agent = default_agent
191
+ # Non-fatal startup warnings (e.g. a default_agent that could not
192
+ # be applied); the TUI renders these in its banner so they are
193
+ # visible inside the interface rather than on stderr.
194
+ self.startup_warnings: list[str] = []
195
+ # Original system prompt assembled at session start — used by
196
+ # /agent default to restore the original agent.md prompt.
197
+ self._default_system_prompt = system_prompt
186
198
  # Monotonic cancel identity: cancel() bumps this counter, so a
187
199
  # worker from a cancelled run can tell it was cancelled even
188
200
  # after the next run clears the shared event.
@@ -708,6 +720,37 @@ class Session:
708
720
  self.stream = merged["stream"]
709
721
  return True, f"switched to {name} ({self.model})"
710
722
 
723
+ def switch_agent(self, name: str) -> tuple[bool, str]:
724
+ """Switch the main agent's system prompt to a named agent.
725
+
726
+ Agent prompt files are discovered from the ``prompts/agents/`` package
727
+ directory (see ``python_agent_harness.prompts.discover_agents``).
728
+ The pseudo-name ``default`` restores the original system prompt
729
+ saved at session construction. Returns (success, message).
730
+ """
731
+ if name == RESERVED_AGENT_NAME:
732
+ self.system_prompt = self._default_system_prompt
733
+ self.store.system_prompt = self._default_system_prompt
734
+ return True, "switched to default agent"
735
+
736
+ agents = discover_agents()
737
+ prompt_file = agents.get(name)
738
+ if prompt_file is None:
739
+ available = ", ".join(sorted([RESERVED_AGENT_NAME, *agents.keys()]))
740
+ return False, f"unknown agent: {name} (available: {available})"
741
+ from .prompts import assemble_agent_prompt, load_agent_prompt
742
+
743
+ agent_prompt = load_agent_prompt(prompt_file, skill_dir=self._skill_dir)
744
+ if agent_prompt is None:
745
+ return False, f"agent {name}: prompt file not found or empty: {prompt_file}"
746
+ self.system_prompt = assemble_agent_prompt(
747
+ self.project_dir,
748
+ agent_prompt,
749
+ context_path=self._configured_context_path,
750
+ )
751
+ self.store.system_prompt = self.system_prompt
752
+ return True, f"switched to {name}"
753
+
711
754
  # ------------------------------------------------------------------
712
755
  # direct commands: compact / summary (no agent loop)
713
756
  # ------------------------------------------------------------------
@@ -752,7 +795,7 @@ class Session:
752
795
  self.last_messages = compacted_messages(summary, user_prompt_texts(messages))
753
796
  self.auto_save(self.last_messages, self.system_prompt)
754
797
  self.notify("compact")
755
- return True, "Buffer compacted successfully."
798
+ return True, "Session compacted successfully."
756
799
  except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
757
800
  self.log(f"compaction failed: {e}")
758
801
  return False, f"Compaction failed: {e}"