python-agent-harness 1.5.6.3__tar.gz → 1.5.6.5__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 (91) hide show
  1. {python_agent_harness-1.5.6.3/python_agent_harness.egg-info → python_agent_harness-1.5.6.5}/PKG-INFO +3 -4
  2. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/README.md +2 -3
  3. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/__init__.py +1 -4
  5. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/agent.py +20 -2
  6. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/models.py +16 -6
  7. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/token_estimator.py +4 -1
  8. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/cli.py +12 -17
  9. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/headless.py +42 -76
  10. python_agent_harness-1.5.6.5/python_agent_harness/entry/restore.py +149 -0
  11. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/server.py +10 -13
  12. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agent.md +1 -0
  13. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/agents/reviewer.md +0 -2
  14. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/session/config.py +63 -38
  15. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/session/session.py +43 -12
  16. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/__init__.py +1 -8
  17. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/base.py +1 -2
  18. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/bash.py +109 -1
  19. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/diffapply.py +1 -1
  20. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit.py +4 -1
  21. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/filesystem.py +6 -96
  22. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep.py +14 -7
  23. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/grep_mac.py +13 -61
  24. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/commands.py +25 -105
  25. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/core.py +1 -1
  26. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/render.py +38 -3
  27. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5/python_agent_harness.egg-info}/PKG-INFO +3 -4
  28. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/SOURCES.txt +1 -7
  29. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/tests/test_invariants.py +16 -17
  30. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/tests/test_scenarios.py +58 -0
  31. python_agent_harness-1.5.6.3/python_agent_harness/lsp/__init__.py +0 -14
  32. python_agent_harness-1.5.6.3/python_agent_harness/lsp/client.py +0 -493
  33. python_agent_harness-1.5.6.3/python_agent_harness/lsp/config.py +0 -108
  34. python_agent_harness-1.5.6.3/python_agent_harness/lsp/manager.py +0 -117
  35. python_agent_harness-1.5.6.3/python_agent_harness/tools/insert.py +0 -87
  36. python_agent_harness-1.5.6.3/python_agent_harness/tools/lsp.py +0 -287
  37. python_agent_harness-1.5.6.3/python_agent_harness/tools/mkdir.py +0 -32
  38. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/LICENSE +0 -0
  39. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/__main__.py +0 -0
  40. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/__init__.py +0 -0
  41. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/context_manager.py +0 -0
  42. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/planmode.py +0 -0
  43. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/subagent.py +0 -0
  44. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/core/tool_runner.py +0 -0
  45. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/__init__.py +0 -0
  46. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/controller.py +0 -0
  47. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/entry/view.py +0 -0
  48. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/io/__init__.py +0 -0
  49. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/io/attachments.py +0 -0
  50. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/io/diffrender.py +0 -0
  51. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/io/persistence.py +0 -0
  52. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/io/text_filter.py +0 -0
  53. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/__init__.py +0 -0
  54. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/llm/client.py +0 -0
  55. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/__init__.py +0 -0
  56. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/client.py +0 -0
  57. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/config.py +0 -0
  58. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/mcp/manager.py +0 -0
  59. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/__init__.py +0 -0
  60. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/build-switch.md +0 -0
  61. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/commands/explain.md +0 -0
  62. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/compact.md +0 -0
  63. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/core.py +0 -0
  64. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/initialize.md +0 -0
  65. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan-mode.md +0 -0
  66. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/plan.md +0 -0
  67. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/review.md +0 -0
  68. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/subagent.md +0 -0
  69. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/summary.md +0 -0
  70. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  71. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/prompts/title.md +0 -0
  72. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/session/__init__.py +0 -0
  73. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/session/commands.py +0 -0
  74. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/agent_tool.py +0 -0
  75. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/edit_mac.py +0 -0
  76. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob.py +0 -0
  77. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/glob_mac.py +0 -0
  78. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/mcp.py +0 -0
  79. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/planexit.py +0 -0
  80. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/question.py +0 -0
  81. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/read.py +0 -0
  82. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/skill.py +0 -0
  83. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/todo.py +0 -0
  84. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tools/write.py +0 -0
  85. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/__init__.py +0 -0
  86. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness/tui/input.py +0 -0
  87. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  88. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
  89. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/requires.txt +0 -0
  90. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/python_agent_harness.egg-info/top_level.txt +0 -0
  91. {python_agent_harness-1.5.6.3 → python_agent_harness-1.5.6.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.3
3
+ Version: 1.5.6.5
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -74,7 +74,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
74
74
  - **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
75
75
  - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
76
76
  - **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
77
- - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (incl. unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
77
+ - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
78
78
  - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
79
79
  - **Persistent sessions** — auto-saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and `/restore --latest` / `/sessions`.
80
80
  - **Focused TUI** — Rich-based with pinned status bar, Todos panel, inline diff rendering, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, `Ctrl-C` cancels.
@@ -191,7 +191,6 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
191
191
  - **`subagent_llm`** — LLM config for `Agent` tool. Unset values inherit from `llm`. Set `profile` to reuse a `models` entry. Precedence: profile > `subagent_llm` > `llm` > env vars.
192
192
  - **`default_agent`** — agent name to use at session start (must exist in `prompts/agents/`). `null` = built-in default.
193
193
  - **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
194
- - **`lsp.servers`** — per-extension LSP server overrides for the `LSP` tool. Keys are extensions (`.py`, `.cpp`); each has `command` (argv) and optional `language_id`. Layers on top of `DEFAULT_SERVERS` in `lsp/manager.py`.
195
194
  - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
196
195
  - **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
197
196
  - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
@@ -212,7 +211,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
212
211
  | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
213
212
  | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
214
213
  | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
215
- | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
214
+ | `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
216
215
  | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
217
216
 
218
217
  ## Development
@@ -40,7 +40,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
40
40
  - **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early; failed tool calls are sanitized. Transient API failures (`429` / `5xx`) retry with exponential backoff. Auth-expired codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env.
41
41
  - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
42
42
  - **Image & text attachments** — attach with `@path` (e.g. `@screenshot.png`, `@README.md`): images (PNG/JPEG/GIF/WebP, up to 20 MB) become multimodal `image_url` parts; text files are inlined. Web URLs supported for images.
43
- - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (incl. unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
43
+ - **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Edit` (incl. unified diffs), `Write`, `Bash`, `Skill`, `Question`, `PlanExit`. Read-only tool rounds (`Read`, `Glob`, `Grep`, `Skill`) dispatch concurrently; async tools (`Bash`, `Agent`) can also run concurrently. Results always delivered in emitted order.
44
44
  - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
45
45
  - **Persistent sessions** — auto-saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and `/restore --latest` / `/sessions`.
46
46
  - **Focused TUI** — Rich-based with pinned status bar, Todos panel, inline diff rendering, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, `Ctrl-C` cancels.
@@ -157,7 +157,6 @@ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environme
157
157
  - **`subagent_llm`** — LLM config for `Agent` tool. Unset values inherit from `llm`. Set `profile` to reuse a `models` entry. Precedence: profile > `subagent_llm` > `llm` > env vars.
158
158
  - **`default_agent`** — agent name to use at session start (must exist in `prompts/agents/`). `null` = built-in default.
159
159
  - **`paths.context_path` / `paths.skill_path`** — locations for context files and skills. Default: project-local `contexts/` and `skills/`.
160
- - **`lsp.servers`** — per-extension LSP server overrides for the `LSP` tool. Keys are extensions (`.py`, `.cpp`); each has `command` (argv) and optional `language_id`. Layers on top of `DEFAULT_SERVERS` in `lsp/manager.py`.
161
160
  - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
162
161
  - **`headless` / `serve`** — optional budget limits: `max_rounds` (LLM rounds) and `timeout` (wall-clock seconds). `--max-rounds` / `--timeout` override per call; `<= 0` or `null` disables. Unset = unlimited.
163
162
  - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
@@ -178,7 +177,7 @@ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core beh
178
177
  | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
179
178
  | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
180
179
  | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
181
- | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
180
+ | `compaction` / `summary` / `title` | `compact.md` / `summary.md` / `title.md` |
182
181
  | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
183
182
 
184
183
  ## Development
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.6.3"
7
+ version = "1.5.6.5"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -3,20 +3,17 @@
3
3
  from .core.models import AgentMode, ImagePart, Message, TextPart, ToolCall, ToolSpec
4
4
  from .entry.cli import main # noqa: F401 (public lib surface: main entry point)
5
5
  from .llm.client import Client, LLMClient
6
- from .lsp.config import LSPConfig, LSPServerConfig
7
6
  from .mcp.config import MCPConfig, MCPServerConfig
8
7
  from .mcp.manager import MCPManager
9
8
  from .session.session import Session
10
9
 
11
- __version__ = "1.5.6.3"
10
+ __version__ = "1.5.6.5"
12
11
 
13
12
  __all__ = [
14
13
  "Client",
15
14
  "LLMClient",
16
15
  "Session",
17
16
  "AgentMode",
18
- "LSPConfig",
19
- "LSPServerConfig",
20
17
  "MCPConfig",
21
18
  "MCPManager",
22
19
  "MCPServerConfig",
@@ -471,8 +471,9 @@ class AgentLoop:
471
471
  # after successful responses, so without this the
472
472
  # interrupted turn would never reach the session
473
473
  # file — the next turn's save would overwrite it
474
- # without ever containing it.
475
- session.auto_save(salvaged, self.system)
474
+ # without ever containing it. flush bypasses the
475
+ # debounce so the partial turn is not lost.
476
+ session.flush_auto_save(salvaged)
476
477
  # The title is generated on the first save, not
477
478
  # only on clean completion — an interrupted session
478
479
  # still gets a meaningful name (one-shot; no-op when
@@ -484,6 +485,9 @@ class AgentLoop:
484
485
  # no-op when the title already exists or generation
485
486
  # is in flight)
486
487
  session.generate_session_title()
488
+ # The per-round auto_save is debounced; flush at run
489
+ # end so the final state always reaches the file.
490
+ session.flush_auto_save(salvaged)
487
491
  return self.result
488
492
 
489
493
  def _next_state(self) -> str:
@@ -552,6 +556,20 @@ class AgentLoop:
552
556
  self._just_compacted = True
553
557
  self.info["compacted"] = True
554
558
  return
559
+ # Compaction failed (empty summary, no reducible
560
+ # prompts, or an exception -- see ContextManager.
561
+ # compact): the history is unchanged, so without a
562
+ # guard the next WAIT round sees the same
563
+ # above-trigger ratio and compacts again, re-billing
564
+ # the summary request every round for nothing.
565
+ # Remember the current ratio as the floor -- reusing
566
+ # the same mechanism as the "still above trigger
567
+ # after a successful compaction" case above -- so
568
+ # the guard below suppresses re-compaction until the
569
+ # context grows meaningfully past it, at which point
570
+ # a retry is warranted (the conversation really did
571
+ # grow) rather than a no-op loop.
572
+ self._compaction_floor = ratio
555
573
  else:
556
574
  self._compaction_floor = None
557
575
  self._just_compacted = False
@@ -69,6 +69,15 @@ class ImagePart:
69
69
  path: str | None = None
70
70
  url: str | None = None
71
71
 
72
+ def __post_init__(self) -> None:
73
+ # to_api() runs on every round (token estimation + API payload),
74
+ # so the base64 data URL is encoded once, not per call.
75
+ self._data_url: str | None = None
76
+ if self.data is not None:
77
+ self._data_url = (
78
+ f"data:{self.media_type};base64,{base64.b64encode(self.data).decode('ascii')}"
79
+ )
80
+
72
81
  @classmethod
73
82
  def from_url(cls, url: str) -> ImagePart:
74
83
  return cls(url=url)
@@ -76,13 +85,9 @@ class ImagePart:
76
85
  def to_api(self) -> dict[str, Any]:
77
86
  if self.url:
78
87
  return {"type": "image_url", "image_url": {"url": self.url}}
79
- if self.data is None:
88
+ if self._data_url is None:
80
89
  raise ValueError("ImagePart requires either data or url")
81
- b64 = base64.b64encode(self.data).decode("ascii")
82
- return {
83
- "type": "image_url",
84
- "image_url": {"url": f"data:{self.media_type};base64,{b64}"},
85
- }
90
+ return {"type": "image_url", "image_url": {"url": self._data_url}}
86
91
 
87
92
 
88
93
  # Type alias for the individual parts a Message.content list may contain.
@@ -155,6 +160,11 @@ class Message:
155
160
  injected: bool = False # harness-injected (nudge/plan/build-switch), not user input
156
161
 
157
162
  def to_api(self) -> dict[str, Any]:
163
+ # Not cached: the per-message dict is cheap to rebuild and
164
+ # caching it (returning the same object each call) would make a
165
+ # mutation by any caller corrupt later rounds and token
166
+ # estimation. The one expensive step — base64-encoding an image
167
+ # — is cached on ``ImagePart`` instead (see ImagePart.__post_init__).
158
168
  d: dict[str, Any] = {"role": self.role}
159
169
  if self.content is not None:
160
170
  content = self._api_content()
@@ -45,7 +45,10 @@ def estimate_tokens(text: str) -> int:
45
45
  """Estimate tokens in TEXT: Latin ~4 chars/token, CJK ~2 chars/token."""
46
46
  if not text:
47
47
  return 0
48
- cjk = len(_CJK_RE.findall(text))
48
+ # subn counts CJK chars in one C-speed pass without allocating the
49
+ # per-match list findall would build (this runs on the full payload
50
+ # every round).
51
+ cjk = _CJK_RE.subn("", text)[1]
49
52
  latin = len(text) - cjk
50
53
  return round(latin / 4.0 + cjk / 2.0)
51
54
 
@@ -45,11 +45,6 @@ def make_session(
45
45
  settings = config.load_llm_config(config_path)
46
46
  paths = config.load_paths_config(config_path)
47
47
  mcp_config = config.load_mcp_config(config_path)
48
- # Validate lsp.servers eagerly so a malformed override fails fast at
49
- # session start (with a clear message) instead of poisoning the first
50
- # LSP tool call mid-session. The LSP manager re-reads it lazily via
51
- # config_path; this call only surfaces config errors early.
52
- config.load_lsp_config(config_path)
53
48
  model = model or settings["model"]
54
49
  # resolve sub-agent overrides against the EFFECTIVE main settings
55
50
  # (so a CLI/caller model override is inherited too when the
@@ -277,12 +272,20 @@ def cmd_serve(args: argparse.Namespace) -> int:
277
272
  def cmd_config(args: argparse.Namespace) -> int:
278
273
  path = config._config_path(args.path)
279
274
  if args.init:
280
- path.parent.mkdir(parents=True, exist_ok=True)
281
- if path.exists():
275
+ # 0o700 dir / 0o600 file: the config holds the API key, so it must
276
+ # not be group/world-readable (mode applies to a NEW leaf dir only;
277
+ # an existing directory keeps its permissions).
278
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
279
+ template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
280
+ try:
281
+ # O_EXCL: create-only, atomically -- no exists()/write race, and
282
+ # a pre-planted symlink at PATH is refused instead of followed.
283
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
284
+ except FileExistsError:
282
285
  print(f"config already exists: {path} (delete it first to overwrite)")
283
286
  return 1
284
- template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
285
- path.write_text(template, encoding="utf-8")
287
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
288
+ f.write(template)
286
289
  print(f"wrote config template: {path}")
287
290
  return 0
288
291
  settings = config.load_llm_config(args.path)
@@ -345,14 +348,6 @@ def cmd_config(args: argparse.Namespace) -> int:
345
348
  print(f" {pattern}: {size}")
346
349
  else:
347
350
  print("context_windows: (none configured — built-in table in config.py applies)")
348
- # Show LSP server overrides (per-extension, layered over the built-ins)
349
- lsp_config = config.load_lsp_config(args.path)
350
- if lsp_config.servers:
351
- print("lsp servers:")
352
- for ext, server in sorted(lsp_config.servers.items()):
353
- print(f" {ext}: language_id={server.language_id}, command={' '.join(server.command)}")
354
- else:
355
- print("lsp servers: (none configured — built-in DEFAULT_SERVERS in lsp/manager.py applies)")
356
351
  # Show default agent
357
352
  default_agent = config.load_default_agent(args.path)
358
353
  print(f"default_agent: {default_agent or '(default: agent.md)'}")
@@ -59,8 +59,8 @@ def _as_error(value: Any) -> dict[str, Any]:
59
59
  ``HeadlessView.errors`` accumulates whatever the notifier passed:
60
60
  the agent loop sends a structured ``{"code", "message"}`` for
61
61
  classified failures (``budget``/``timeout``), other sites send a
62
- plain string whose code is then derived from the text, and the LSP
63
- client sends a ``{"message": ...}`` dict. A nested
62
+ plain string whose code is then derived from the text, and some
63
+ callers send a ``{"message": ...}`` dict. A nested
64
64
  ``{"error": {...}}`` shape is unwrapped; a dict carrying only a
65
65
  ``code`` falls back to that code for its message so the two never
66
66
  disagree on emptiness.
@@ -106,8 +106,7 @@ def error_display_text(value: Any) -> str:
106
106
 
107
107
  ``notify("error", ...)`` carries either a plain message string or a
108
108
  structured ``{"code", "message"}`` payload (the agent loop sends
109
- the latter where the outcome is known; the LSP client sends a
110
- ``{"message": ...}`` dict too). Views render this rather than the
109
+ the latter where the outcome is known). Views render this rather than the
111
110
  raw value, so a structured error does not reach a human as a dict
112
111
  repr.
113
112
 
@@ -138,6 +137,25 @@ def _cancelled_now(session: Any) -> bool:
138
137
  return event is not None and event.is_set() is True
139
138
 
140
139
 
140
+ def usage_snapshot(session: Any) -> dict[str, Any] | None:
141
+ """Cumulative input/output tokens and rounds of the current run.
142
+
143
+ Reads the CURRENT ``usage_totals`` (``Controller.submit`` swaps in a
144
+ fresh dict per run, so a reference captured before submit would
145
+ always snapshot zeros). None when the session keeps no totals.
146
+ """
147
+ totals = getattr(session, "usage_totals", None)
148
+ if not isinstance(totals, dict):
149
+ return None
150
+ usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
151
+ with totals.get("_lock", threading.Lock()):
152
+ for key in ("input", "output", "rounds"):
153
+ value = totals.get(key)
154
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
155
+ usage[key] = int(value)
156
+ return usage
157
+
158
+
141
159
  def final_answer_text(session: Session) -> str:
142
160
  """The run's final assistant answer, TUI-filtered.
143
161
 
@@ -286,69 +304,25 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
286
304
  file, and restores the saved agent/model. Returns False (with a
287
305
  message on *err*) when no matching session exists.
288
306
  """
289
- from ..io.persistence import (
290
- SessionPersistence,
291
- find_session_by_title,
292
- parse_saved_body,
293
- title_from_filename,
294
- )
295
-
296
- if not spec or spec in ("--latest", "latest"):
297
- path = SessionPersistence.latest_session()
298
- elif os.path.isfile(os.path.expanduser(spec)):
299
- path = os.path.expanduser(spec)
300
- else:
301
- path = find_session_by_title(spec)
307
+ from .restore import apply_saved_session, load_saved_session, resolve_session_path
308
+
309
+ path = resolve_session_path(spec)
302
310
  if not path or not os.path.isfile(path):
303
311
  err.write(f"no session found: {spec or 'latest'}\n")
304
312
  return False
305
- # surrogateescape must mirror SessionPersistence.save: the saved body
306
- # may carry lone surrogates (the tools read non-UTF-8 files that way),
307
- # and a strict decode raised UnicodeDecodeError here.
308
- with open(path, encoding="utf-8", errors="surrogateescape") as fh:
309
- text = fh.read()
310
- meta = SessionPersistence.parse_metadata(text)
311
- body = SessionPersistence.strip_metadata(text)
312
- messages = parse_saved_body(body)
313
- controller.store.file_path = path
314
- title = title_from_filename(path)
315
- if title:
316
- controller.store.title = title
317
- agent = meta.get("python-agent-harness--agent")
318
- if agent:
319
- success, msg = controller.switch_agent(agent)
320
- if not success:
321
- controller.switch_agent("default")
313
+ try:
314
+ saved = load_saved_session(path)
315
+ except OSError as e:
316
+ err.write(f"cannot read {path}: {e}\n")
317
+ return False
318
+
319
+ def report(level: str, msg: str) -> None:
320
+ # headless output is quiet on success: only problems reach stderr
321
+ if level == "warning":
322
322
  err.write(f"warning: {msg}\n")
323
- model = meta.get("python-agent-harness--model")
324
- if model and model != controller.model:
325
- if model == controller.llm_settings.get("model"):
326
- # The saved model is the session-start default: the
327
- # "default" pseudo-profile restores exactly those settings.
328
- controller.switch_model("default")
329
- else:
330
- success, _ = controller.switch_model(model)
331
- if not success:
332
- profile = next(
333
- (
334
- name
335
- for name, p in controller.model_profiles.items()
336
- if p.get("model") == model
337
- ),
338
- None,
339
- )
340
- if profile:
341
- success, _ = controller.switch_model(profile)
342
- if not success:
343
- err.write(
344
- f"warning: model {model!r} has no matching profile, keeping current model\n"
345
- )
346
- # Replace conversation history: a new generation, so a stale worker
347
- # from a previous run can't clobber the restored session.
348
- controller.run_generation += 1
349
- controller.last_messages = messages
350
- controller.clear_todos()
351
- err.write(f"restored: {os.path.basename(path)} ({len(messages)} messages)\n")
323
+
324
+ apply_saved_session(controller, saved, report)
325
+ err.write(f"restored: {os.path.basename(path)} ({len(saved.messages)} messages)\n")
352
326
  return True
353
327
 
354
328
 
@@ -680,20 +654,12 @@ def run_headless_jsonl(
680
654
  """The terminal result line: snapshots usage and model so every
681
655
  result line — including early failures — carries the same
682
656
  fields for the driving process."""
683
- usage: dict[str, Any] | None = None
684
- # Read the CURRENT usage_totals, not a reference captured before
685
- # submit(): Controller.submit swaps in a fresh dict per run, so
686
- # a pre-submit reference would always snapshot zeros.
687
- totals = getattr(session, "usage_totals", None)
688
- if isinstance(totals, dict):
689
- usage = {"input": 0, "output": 0, "rounds": 0}
690
- with totals.get("_lock", threading.Lock()):
691
- for key in ("input", "output", "rounds"):
692
- value = totals.get(key)
693
- if isinstance(value, (int, float)) and not isinstance(value, bool):
694
- usage[key] = int(value)
695
657
  view.emit_result(
696
- answer, errors=errors, usage=usage, model=session.model, cancelled=cancelled
658
+ answer,
659
+ errors=errors,
660
+ usage=usage_snapshot(session),
661
+ model=session.model,
662
+ cancelled=cancelled,
697
663
  )
698
664
 
699
665
  if restore is not None and not restore_session(controller, restore, view.err):
@@ -0,0 +1,149 @@
1
+ """Shared session-restore logic for the TUI's /restore and headless --restore.
2
+
3
+ Both front ends resolve a saved session the same way (path, title
4
+ substring, or latest), parse it the same way, and apply it to a
5
+ ``Controller`` the same way: repoint the session store, restore the
6
+ saved agent and model, and replace the conversation history. Only the
7
+ reporting differs, so callers pass a ``report(level, message)``
8
+ callback ("ok" for confirmations, "warning" for problems).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ from ..core.models import Message
19
+ from ..io import persistence
20
+
21
+ if TYPE_CHECKING:
22
+ from .controller import Controller
23
+
24
+ AGENT_KEY = "python-agent-harness--agent"
25
+ MODEL_KEY = "python-agent-harness--model"
26
+ PROJECT_KEY = "python-agent-harness--project-dir"
27
+ ROUND_TIMES_KEY = "python-agent-harness--round-times"
28
+
29
+ Report = Callable[[str, str], None]
30
+
31
+
32
+ @dataclass
33
+ class SavedSession:
34
+ """A parsed session file: its path, metadata and conversation."""
35
+
36
+ path: str
37
+ meta: dict[str, str] = field(default_factory=dict)
38
+ messages: list[Message] = field(default_factory=list)
39
+
40
+ @property
41
+ def round_times(self) -> list[float]:
42
+ """Persisted round timestamps; [] when absent or malformed."""
43
+ raw = self.meta.get(ROUND_TIMES_KEY)
44
+ if not raw:
45
+ return []
46
+ try:
47
+ return [float(x) for x in raw.split()]
48
+ except ValueError:
49
+ return []
50
+
51
+
52
+ def resolve_session_path(spec: str) -> str | None:
53
+ """Map SPEC (empty/"latest"/"--latest", a file path, or a title
54
+ substring) to a session file path, or None when nothing matches.
55
+
56
+ The result is not guaranteed to exist: callers check ``isfile``.
57
+ """
58
+ if not spec or spec in ("--latest", "latest"):
59
+ return persistence.SessionPersistence.latest_session()
60
+ expanded = os.path.expanduser(spec)
61
+ if os.path.isfile(expanded):
62
+ return expanded
63
+ return persistence.find_session_by_title(spec)
64
+
65
+
66
+ def load_saved_session(path: str) -> SavedSession:
67
+ """Read and parse the session file at PATH. Raises OSError.
68
+
69
+ surrogateescape mirrors ``SessionPersistence.save``: the saved body
70
+ may carry lone surrogates (non-UTF-8 file content), which a strict
71
+ decode would reject.
72
+ """
73
+ with open(path, encoding="utf-8", errors="surrogateescape") as fh:
74
+ text = fh.read()
75
+ store = persistence.SessionPersistence
76
+ meta = store.parse_metadata(text)
77
+ messages = persistence.parse_saved_body(store.strip_metadata(text))
78
+ return SavedSession(path=path, meta=meta, messages=messages)
79
+
80
+
81
+ def restore_agent(controller: Controller, agent: str | None, report: Report) -> None:
82
+ """Switch to the saved AGENT; fall back to the default agent (so no
83
+ stale tool exclusions linger) when it no longer exists."""
84
+ if not agent:
85
+ return
86
+ try:
87
+ success, msg = controller.switch_agent(agent)
88
+ if success:
89
+ report("ok", msg)
90
+ else:
91
+ controller.switch_agent("default")
92
+ report("warning", msg)
93
+ except Exception as e: # noqa: BLE001 - a bad agent must not abort the restore
94
+ report("warning", f"could not restore agent: {e}")
95
+
96
+
97
+ def restore_model(controller: Controller, model: str | None, report: Report) -> None:
98
+ """Switch back to the saved raw MODEL name through a profile.
99
+
100
+ Profile switching also fixes base_url/api_key/temperature, which a
101
+ bare model-name change would leave pointing at the wrong endpoint.
102
+ Order: the "default" pseudo-profile when MODEL is the session-start
103
+ model, else MODEL as a profile name, else a profile whose model
104
+ matches. Without a match the current model stays active.
105
+ """
106
+ if not model or model == controller.model:
107
+ return
108
+ try:
109
+ if model == controller.llm_settings.get("model"):
110
+ _, msg = controller.switch_model("default")
111
+ report("ok", msg)
112
+ return
113
+ success, msg = controller.switch_model(model)
114
+ if not success:
115
+ profile = next(
116
+ (name for name, p in controller.model_profiles.items() if p.get("model") == model),
117
+ None,
118
+ )
119
+ if profile:
120
+ success, msg = controller.switch_model(profile)
121
+ if success:
122
+ report("ok", msg)
123
+ else:
124
+ report("warning", f"model {model!r} has no matching profile, keeping current model")
125
+ except Exception as e: # noqa: BLE001 - a bad model must not abort the restore
126
+ report("warning", f"could not restore model: {e}")
127
+
128
+
129
+ def apply_saved_session(controller: Controller, saved: SavedSession, report: Report) -> None:
130
+ """Make SAVED the controller's current session.
131
+
132
+ The conversation history is replaced in BOTH places the runtime
133
+ reads it: ``conversation_history`` (what the next run sends) and
134
+ ``last_messages`` (what the views render). ``run_generation`` is
135
+ bumped so a worker still winding down from a cancelled run cannot
136
+ clobber the restored history with its salvaged one.
137
+ """
138
+ store = controller.store
139
+ store.round_times = saved.round_times
140
+ store.file_path = saved.path
141
+ title = persistence.title_from_filename(saved.path)
142
+ if title:
143
+ store.title = title
144
+ restore_agent(controller, saved.meta.get(AGENT_KEY), report)
145
+ restore_model(controller, saved.meta.get(MODEL_KEY), report)
146
+ controller.run_generation += 1
147
+ controller.conversation_history = list(saved.messages)
148
+ controller.last_messages = list(saved.messages)
149
+ controller.clear_todos()
@@ -130,7 +130,14 @@ from typing import Any, TextIO
130
130
  from ..core.models import api_safe
131
131
  from ..session.session import Session
132
132
  from .controller import Controller
133
- from .headless import PROTOCOL_VERSION, JsonlView, _restorable_handler, final_answer_text
133
+ from .headless import (
134
+ PROTOCOL_VERSION,
135
+ JsonlView,
136
+ _cancelled_now,
137
+ _restorable_handler,
138
+ final_answer_text,
139
+ usage_snapshot,
140
+ )
134
141
 
135
142
  DEFAULT_ANSWER_TIMEOUT = 0.0 # wait forever for a host answer
136
143
 
@@ -581,8 +588,7 @@ class AgentServer:
581
588
  # -- run execution ------------------------------------------------------------
582
589
 
583
590
  def _cancelled_now(self) -> bool:
584
- event = getattr(self.session, "cancel_event", None)
585
- return event is not None and event.is_set() is True
591
+ return _cancelled_now(self.session)
586
592
 
587
593
  def _final_answer(self) -> str:
588
594
  """The run's final assistant answer, TUI-filtered (headless shape).
@@ -595,16 +601,7 @@ class AgentServer:
595
601
 
596
602
  def _usage_snapshot(self) -> dict[str, Any] | None:
597
603
  """Cumulative input/output tokens and rounds of the current run."""
598
- totals = getattr(self.session, "usage_totals", None)
599
- if not isinstance(totals, dict):
600
- return None
601
- usage: dict[str, Any] = {"input": 0, "output": 0, "rounds": 0}
602
- with totals.get("_lock", threading.Lock()):
603
- for key in ("input", "output", "rounds"):
604
- value = totals.get(key)
605
- if isinstance(value, (int, float)) and not isinstance(value, bool):
606
- usage[key] = int(value)
607
- return usage
604
+ return usage_snapshot(self.session)
608
605
 
609
606
  def _execute_run(self, prompt: str, run_id: str) -> None:
610
607
  """Run one prompt to completion, streaming lines as they happen.
@@ -56,6 +56,7 @@ The user will primarily request you perform software engineering tasks. This inc
56
56
  - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
57
57
  - Implement the solution using all tools available to you
58
58
  - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
59
+ - Test edge cases to ensure your solution is robust.
59
60
  - VERY IMPORTANT:
60
61
  - When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
61
62
  - NEVER commit changes unless the user explicitly asks you to.
@@ -3,9 +3,7 @@ name: reviewer
3
3
  exclude_tools:
4
4
  - Bash
5
5
  - Edit
6
- - Insert
7
6
  - Write
8
- - Mkdir
9
7
  ---
10
8
 
11
9
  # Role and Behavior