python-agent-harness 1.5.6.2__tar.gz → 1.5.6.4__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 (90) hide show
  1. {python_agent_harness-1.5.6.2/python_agent_harness.egg-info → python_agent_harness-1.5.6.4}/PKG-INFO +2 -3
  2. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/README.md +1 -2
  3. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/__init__.py +1 -4
  5. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/agent.py +14 -0
  6. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/models.py +37 -5
  7. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/cli.py +0 -13
  8. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/headless.py +24 -8
  9. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/server.py +19 -2
  10. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/io/persistence.py +10 -1
  11. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/agent.md +4 -3
  12. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/agents/reviewer.md +0 -2
  13. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/session/config.py +0 -38
  14. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/session/session.py +7 -11
  15. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/__init__.py +1 -8
  16. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/base.py +1 -2
  17. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/bash.py +2 -0
  18. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/diffapply.py +1 -1
  19. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/edit.py +47 -12
  20. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/filesystem.py +6 -96
  21. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/grep.py +0 -1
  22. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tui/commands.py +4 -2
  23. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tui/core.py +1 -1
  24. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tui/render.py +47 -5
  25. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4/python_agent_harness.egg-info}/PKG-INFO +2 -3
  26. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness.egg-info/SOURCES.txt +0 -7
  27. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/tests/test_invariants.py +16 -17
  28. python_agent_harness-1.5.6.2/python_agent_harness/lsp/__init__.py +0 -14
  29. python_agent_harness-1.5.6.2/python_agent_harness/lsp/client.py +0 -314
  30. python_agent_harness-1.5.6.2/python_agent_harness/lsp/config.py +0 -108
  31. python_agent_harness-1.5.6.2/python_agent_harness/lsp/manager.py +0 -117
  32. python_agent_harness-1.5.6.2/python_agent_harness/tools/insert.py +0 -87
  33. python_agent_harness-1.5.6.2/python_agent_harness/tools/lsp.py +0 -287
  34. python_agent_harness-1.5.6.2/python_agent_harness/tools/mkdir.py +0 -32
  35. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/LICENSE +0 -0
  36. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/__main__.py +0 -0
  37. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/__init__.py +0 -0
  38. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/context_manager.py +0 -0
  39. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/planmode.py +0 -0
  40. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/subagent.py +0 -0
  41. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/token_estimator.py +0 -0
  42. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/core/tool_runner.py +0 -0
  43. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/__init__.py +0 -0
  44. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/controller.py +0 -0
  45. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/entry/view.py +0 -0
  46. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/io/__init__.py +0 -0
  47. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/io/attachments.py +0 -0
  48. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/io/diffrender.py +0 -0
  49. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/io/text_filter.py +0 -0
  50. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/llm/__init__.py +0 -0
  51. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/llm/client.py +0 -0
  52. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/mcp/__init__.py +0 -0
  53. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/mcp/client.py +0 -0
  54. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/mcp/config.py +0 -0
  55. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/mcp/manager.py +0 -0
  56. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/__init__.py +0 -0
  57. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/build-switch.md +0 -0
  58. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/commands/explain.md +0 -0
  59. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/compact.md +0 -0
  60. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/core.py +0 -0
  61. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/initialize.md +0 -0
  62. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/plan-mode.md +0 -0
  63. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/plan.md +0 -0
  64. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/review.md +0 -0
  65. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/subagent.md +0 -0
  66. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/summary.md +0 -0
  67. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  68. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/prompts/title.md +0 -0
  69. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/session/__init__.py +0 -0
  70. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/session/commands.py +0 -0
  71. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/agent_tool.py +0 -0
  72. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/edit_mac.py +0 -0
  73. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/glob.py +0 -0
  74. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/glob_mac.py +0 -0
  75. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/grep_mac.py +0 -0
  76. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/mcp.py +0 -0
  77. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/planexit.py +0 -0
  78. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/question.py +0 -0
  79. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/read.py +0 -0
  80. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/skill.py +0 -0
  81. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/todo.py +0 -0
  82. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tools/write.py +0 -0
  83. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tui/__init__.py +0 -0
  84. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness/tui/input.py +0 -0
  85. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  86. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness.egg-info/entry_points.txt +0 -0
  87. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness.egg-info/requires.txt +0 -0
  88. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/python_agent_harness.egg-info/top_level.txt +0 -0
  89. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/setup.cfg +0 -0
  90. {python_agent_harness-1.5.6.2 → python_agent_harness-1.5.6.4}/tests/test_scenarios.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.2
3
+ Version: 1.5.6.4
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`).
@@ -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`).
@@ -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.2"
7
+ version = "1.5.6.4"
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.2"
10
+ __version__ = "1.5.6.4"
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",
@@ -552,6 +552,20 @@ class AgentLoop:
552
552
  self._just_compacted = True
553
553
  self.info["compacted"] = True
554
554
  return
555
+ # Compaction failed (empty summary, no reducible
556
+ # prompts, or an exception -- see ContextManager.
557
+ # compact): the history is unchanged, so without a
558
+ # guard the next WAIT round sees the same
559
+ # above-trigger ratio and compacts again, re-billing
560
+ # the summary request every round for nothing.
561
+ # Remember the current ratio as the floor -- reusing
562
+ # the same mechanism as the "still above trigger
563
+ # after a successful compaction" case above -- so
564
+ # the guard below suppresses re-compaction until the
565
+ # context grows meaningfully past it, at which point
566
+ # a retry is warranted (the conversation really did
567
+ # grow) rather than a no-op loop.
568
+ self._compaction_floor = ratio
555
569
  else:
556
570
  self._compaction_floor = None
557
571
  self._just_compacted = False
@@ -5,9 +5,30 @@ from __future__ import annotations
5
5
  import base64
6
6
  import enum
7
7
  import json
8
+ import re
8
9
  from dataclasses import dataclass, field
9
10
  from typing import Any, Union
10
11
 
12
+ # Lone surrogates (\ud800-\udfff) reach a str when a file is decoded with
13
+ # errors="surrogateescape" — which every tool deliberately does, so a
14
+ # non-UTF-8 source file round-trips instead of becoming U+FFFD. Such a
15
+ # string cannot be encoded to UTF-8, so anything that leaves the process
16
+ # (a JSON request body, a terminal write) must replace them first.
17
+ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
18
+
19
+
20
+ def api_safe(text: str) -> str:
21
+ """TEXT with lone surrogates replaced by U+FFFD, safe to serialize.
22
+
23
+ The tools read files with ``errors="surrogateescape"``, so a
24
+ conversation can carry lone surrogates from a non-UTF-8 (Latin-1,
25
+ GBK, ...) source. A request body containing one cannot be encoded
26
+ to UTF-8 — ``httpx`` raised ``UnicodeEncodeError`` — so they are
27
+ replaced at the OUTBOUND boundary only: the stored history, the
28
+ session file and the file on disk keep the exact bytes.
29
+ """
30
+ return _SURROGATE_RE.sub("\ufffd", text)
31
+
11
32
 
12
33
  class AgentMode(enum.Enum):
13
34
  BUILD = "build"
@@ -136,7 +157,10 @@ class Message:
136
157
  def to_api(self) -> dict[str, Any]:
137
158
  d: dict[str, Any] = {"role": self.role}
138
159
  if self.content is not None:
139
- d["content"] = self._api_content()
160
+ content = self._api_content()
161
+ # outbound boundary: a lone surrogate cannot be encoded into
162
+ # the JSON body (see api_safe)
163
+ d["content"] = api_safe(content) if isinstance(content, str) else content
140
164
  if self.tool_calls:
141
165
  d["tool_calls"] = [
142
166
  {
@@ -144,9 +168,13 @@ class Message:
144
168
  "type": "function",
145
169
  "function": {
146
170
  "name": tc.name,
147
- "arguments": tc.arguments
148
- if isinstance(tc.arguments, str)
149
- else json.dumps(tc.arguments, ensure_ascii=False),
171
+ # ensure_ascii=False keeps non-ASCII readable;
172
+ # api_safe then removes the unencodable surrogates
173
+ "arguments": api_safe(
174
+ tc.arguments
175
+ if isinstance(tc.arguments, str)
176
+ else json.dumps(tc.arguments, ensure_ascii=False)
177
+ ),
150
178
  },
151
179
  }
152
180
  for tc in self.tool_calls
@@ -188,7 +216,11 @@ class Message:
188
216
  @staticmethod
189
217
  def _part_to_api(part: ContentPart) -> Any:
190
218
  """Serialize one content part to its provider-specific dict."""
191
- if isinstance(part, (TextPart, ImagePart)):
219
+ if isinstance(part, TextPart):
220
+ # text parts are a second carrier of file content into the
221
+ # payload, so they need the same outbound treatment
222
+ return {"type": "text", "text": api_safe(part.text)}
223
+ if isinstance(part, ImagePart):
192
224
  return part.to_api()
193
225
  return part
194
226
 
@@ -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
@@ -345,14 +340,6 @@ def cmd_config(args: argparse.Namespace) -> int:
345
340
  print(f" {pattern}: {size}")
346
341
  else:
347
342
  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
343
  # Show default agent
357
344
  default_agent = config.load_default_agent(args.path)
358
345
  print(f"default_agent: {default_agent or '(default: agent.md)'}")
@@ -22,6 +22,7 @@ import sys
22
22
  import threading
23
23
  from typing import Any, TextIO
24
24
 
25
+ from ..core.models import api_safe
25
26
  from ..io.text_filter import strip_final_check
26
27
  from ..session.session import Session
27
28
  from .controller import Controller
@@ -58,8 +59,8 @@ def _as_error(value: Any) -> dict[str, Any]:
58
59
  ``HeadlessView.errors`` accumulates whatever the notifier passed:
59
60
  the agent loop sends a structured ``{"code", "message"}`` for
60
61
  classified failures (``budget``/``timeout``), other sites send a
61
- plain string whose code is then derived from the text, and the LSP
62
- 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
63
64
  ``{"error": {...}}`` shape is unwrapped; a dict carrying only a
64
65
  ``code`` falls back to that code for its message so the two never
65
66
  disagree on emptiness.
@@ -105,8 +106,7 @@ def error_display_text(value: Any) -> str:
105
106
 
106
107
  ``notify("error", ...)`` carries either a plain message string or a
107
108
  structured ``{"code", "message"}`` payload (the agent loop sends
108
- the latter where the outcome is known; the LSP client sends a
109
- ``{"message": ...}`` dict too). Views render this rather than the
109
+ the latter where the outcome is known). Views render this rather than the
110
110
  raw value, so a structured error does not reach a human as a dict
111
111
  repr.
112
112
 
@@ -166,7 +166,13 @@ def final_answer_text(session: Session) -> str:
166
166
  continue
167
167
  text = strip_final_check(msg.text_without_reasoning()).strip()
168
168
  if text:
169
- return text
169
+ # api_safe: the answer is written to stdout (headless prints it
170
+ # verbatim; --json and serve JSON-encode it), and a lone
171
+ # surrogate from a restored non-UTF-8 session raised
172
+ # UnicodeEncodeError there — headless died before writing the
173
+ # result line, and serve's _write_line SWALLOWED it (it is a
174
+ # ValueError), reporting success with an empty stream.
175
+ return api_safe(text)
170
176
  return ""
171
177
 
172
178
 
@@ -295,7 +301,10 @@ def restore_session(controller: Controller, spec: str, err: TextIO) -> bool:
295
301
  if not path or not os.path.isfile(path):
296
302
  err.write(f"no session found: {spec or 'latest'}\n")
297
303
  return False
298
- with open(path, encoding="utf-8") as fh:
304
+ # surrogateescape must mirror SessionPersistence.save: the saved body
305
+ # may carry lone surrogates (the tools read non-UTF-8 files that way),
306
+ # and a strict decode raised UnicodeDecodeError here.
307
+ with open(path, encoding="utf-8", errors="surrogateescape") as fh:
299
308
  text = fh.read()
300
309
  meta = SessionPersistence.parse_metadata(text)
301
310
  body = SessionPersistence.strip_metadata(text)
@@ -417,7 +426,10 @@ def run_headless(
417
426
  return 1
418
427
  answer = final_answer_text(session)
419
428
  if answer:
420
- view.out.write(answer + "\n")
429
+ # api_safe: the answer carries whatever the conversation carried
430
+ # (a restored non-UTF-8 session), and a strict-UTF-8 stdout
431
+ # raised UnicodeEncodeError here — losing the result line.
432
+ view.out.write(api_safe(answer) + "\n")
421
433
  view.out.flush()
422
434
  return 1 if view.errors else 0
423
435
 
@@ -512,7 +524,11 @@ class JsonlView(HeadlessView):
512
524
  line = {"seq": self._seq, "protocol": PROTOCOL_VERSION, **payload}
513
525
  if self.run_id is not None:
514
526
  line["run_id"] = self.run_id
515
- self.out.write(json.dumps(line, ensure_ascii=False, default=str) + "\n")
527
+ # api_safe at the sink: the JSON body is written to the driver's
528
+ # stdout, and a lone surrogate (restored non-UTF-8 session) made
529
+ # the write raise UnicodeEncodeError — the run then ended without
530
+ # its terminal `result` line.
531
+ self.out.write(api_safe(json.dumps(line, ensure_ascii=False, default=str)) + "\n")
516
532
  self.out.flush()
517
533
 
518
534
  def _emit(self, payload: dict[str, Any]) -> None:
@@ -127,6 +127,7 @@ import time
127
127
  import uuid
128
128
  from typing import Any, TextIO
129
129
 
130
+ from ..core.models import api_safe
130
131
  from ..session.session import Session
131
132
  from .controller import Controller
132
133
  from .headless import PROTOCOL_VERSION, JsonlView, _restorable_handler, final_answer_text
@@ -530,7 +531,11 @@ class AgentServer:
530
531
  live run lines get ``seq``/``run_id`` via the view's ``_write``.
531
532
  """
532
533
  text = (
533
- json.dumps({"protocol": PROTOCOL_VERSION, **payload}, ensure_ascii=False, default=str)
534
+ api_safe(
535
+ json.dumps(
536
+ {"protocol": PROTOCOL_VERSION, **payload}, ensure_ascii=False, default=str
537
+ )
538
+ )
534
539
  + "\n"
535
540
  )
536
541
  view = self.view
@@ -892,7 +897,19 @@ class AgentServer:
892
897
  def _read_ops(self) -> None:
893
898
  """The reader loop proper: dispatch ops until EOF/shutdown."""
894
899
  while not self._stopped.is_set():
895
- line = self.inp.readline()
900
+ try:
901
+ line = self.inp.readline()
902
+ except (UnicodeDecodeError, ValueError, OSError) as e:
903
+ # Unreadable input must never take the resident process
904
+ # down. A single non-UTF-8 byte on a strict-UTF-8 stdin
905
+ # (the common C.UTF-8/LANG=en_US.UTF-8 container setup)
906
+ # raised out of serve_forever, which skipped the run drain
907
+ # below: an in-flight run then emitted NO result line and
908
+ # its token usage was lost. Report it as a protocol error
909
+ # and stop reading, so the drain still runs. ValueError
910
+ # covers a stdin closed underneath us.
911
+ self._error("protocol", f"cannot read op: {e}")
912
+ break
896
913
  if not line: # EOF: the host closed the pipe
897
914
  break
898
915
  line = line.strip()
@@ -195,7 +195,16 @@ class SessionPersistence:
195
195
  session_dir().mkdir(parents=True, exist_ok=True)
196
196
  content = conversation_text.rstrip("\n") + "\n\n" + self.metadata_block() + "\n"
197
197
  tmp = path + ".tmp"
198
- Path(tmp).write_text(content, encoding="utf-8")
198
+ # surrogateescape: the tools deliberately read files with it
199
+ # (see atomic_write_text / Edit._string_replace), so a
200
+ # conversation can legitimately carry lone surrogates from a
201
+ # non-UTF-8 source file. A strict write raised
202
+ # UnicodeEncodeError, which is NOT an OSError, so it escaped
203
+ # auto_save's handler: the turn was never saved and a stray
204
+ # <name>.md.tmp was left behind. The matching reads
205
+ # (headless restore_session, TUI /restore and /sessions) use
206
+ # the same error handler so the bytes round-trip.
207
+ Path(tmp).write_text(content, encoding="utf-8", errors="surrogateescape")
199
208
  os.replace(tmp, path)
200
209
  return path
201
210
 
@@ -56,9 +56,10 @@ 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
- - VERY IMPORTANT: 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.
60
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
61
-
59
+ - Test edge cases to ensure your solution is robust.
60
+ - VERY IMPORTANT:
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.
62
+ - NEVER commit changes unless the user explicitly asks you to.
62
63
  - Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
63
64
 
64
65
  # Tool usage policy
@@ -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
@@ -6,7 +6,6 @@ import json
6
6
  import os
7
7
  from pathlib import Path
8
8
 
9
- from ..lsp.config import LSPConfig
10
9
  from ..mcp.config import MCPConfig
11
10
 
12
11
  # ---- context management -------------------------------------------------
@@ -88,14 +87,11 @@ DEFAULT_TOOLS: list[str] = [
88
87
  "Glob",
89
88
  "Grep",
90
89
  "Read",
91
- "Insert",
92
90
  "Edit",
93
91
  "Write",
94
- "Mkdir",
95
92
  "Bash",
96
93
  "Skill",
97
94
  "Question",
98
- "LSP",
99
95
  ]
100
96
 
101
97
  # ---- tool output limits --------------------------------------------------------
@@ -261,15 +257,6 @@ CONFIG_TEMPLATE = """\
261
257
  "context_path": null,
262
258
  "skill_path": null
263
259
  }},
264
- "lsp": {{
265
- "_comment": "Optional per-extension LSP server overrides. Keys are file extensions (e.g. '.py', '.cpp'); 'command' is the server argv; 'language_id' defaults to the extension without the dot. These override the built-in DEFAULT_SERVERS table. Remove this section to use only the built-ins. The server binary must be on PATH. The '.example' entry below is inert (no such extension) — copy it to a real extension like '.cpp' to activate.",
266
- "servers": {{
267
- ".example": {{
268
- "command": ["clangd", "--background-index", "--clang-tidy"],
269
- "language_id": "cpp"
270
- }}
271
- }}
272
- }},
273
260
  "mcp": {{
274
261
  "_comment": "Optional MCP servers (requires: pip install -e '.[mcp]'). Each server's tools become agent tools named mcp__<server>__<tool>. Transports: stdio (spawn command+args, pass through env var names), streamable-http / sse (connect to url, optional headers). 'parallel: true' marks read-only servers whose tools may run concurrently; default is serial. 'timeout' bounds connects, discovery and calls (seconds).",
275
262
  "servers": {{
@@ -526,31 +513,6 @@ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
526
513
  return MCPConfig.from_dict(section.get("servers"))
527
514
 
528
515
 
529
- def load_lsp_config(path: str | os.PathLike | None = None) -> LSPConfig:
530
- """Load per-extension LSP server overrides from the config file.
531
-
532
- Reads the optional ``lsp.servers`` object (keyed by file extension,
533
- e.g. ``".py"``) and returns an ``LSPConfig`` (empty when the file
534
- has no ``lsp`` section or no servers). These entries layer on top of
535
- the built-in ``DEFAULT_SERVERS`` table in ``lsp/manager.py``.
536
-
537
- Malformed entries raise ValueError so config errors surface at
538
- session start rather than the first LSP call.
539
- """
540
- data = _read_config(path)
541
- section = data.get("lsp") or {}
542
- if not isinstance(section, dict):
543
- raise ValueError(f"config file {_config_path(path)}: lsp must be an object")
544
- servers = section.get("servers") or {}
545
- if not isinstance(servers, dict):
546
- raise ValueError(f"config file {_config_path(path)}: lsp.servers must be an object")
547
- try:
548
- return LSPConfig.from_dict(servers)
549
- except ValueError as e:
550
- # Re-raise with the config-file path for a clearer message.
551
- raise ValueError(f"config file {_config_path(path)}: {e}") from e
552
-
553
-
554
516
  def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]:
555
517
  """Load named LLM profiles from the config file's ``models`` object.
556
518
 
@@ -370,7 +370,7 @@ class Session:
370
370
  # write_file/create_directory), so plan mode stays read-only
371
371
  # by refusing every mcp__ tool.
372
372
  if self.plan_mode.is_plan and (
373
- name in ("Write", "Edit", "Insert", "Mkdir", "Bash") or name.startswith("mcp__")
373
+ name in ("Write", "Edit", "Bash") or name.startswith("mcp__")
374
374
  ):
375
375
  blocked = self._plan_blocked(name, args)
376
376
  if blocked:
@@ -467,16 +467,8 @@ class Session:
467
467
  ),
468
468
  self.project_dir,
469
469
  )
470
- if name in ("Edit", "Insert"):
470
+ if name == "Edit":
471
471
  return resolve_path(str(args.get("path", "")), self.project_dir)
472
- if name == "Mkdir":
473
- # Mirrors Mkdir.run: only the parent is ~-expanded.
474
- return os.path.realpath(
475
- os.path.join(
476
- resolve_path(str(args.get("parent", "")), self.project_dir),
477
- str(args.get("name", "")),
478
- )
479
- )
480
472
  return None
481
473
 
482
474
  # ------------------------------------------------------------------
@@ -698,7 +690,11 @@ class Session:
698
690
  self.store.save(text)
699
691
  self._save_error = None
700
692
  return
701
- except OSError as e:
693
+ except (OSError, UnicodeError) as e:
694
+ # UnicodeError too: a save failure must never escape into
695
+ # the agent loop (it unwound the run and lost the turn).
696
+ # The writer uses surrogateescape now, so this is a
697
+ # defensive net rather than the primary fix.
702
698
  self._save_error = str(e)
703
699
  if attempt == 1:
704
700
  time.sleep(0.2)
@@ -9,10 +9,9 @@ from .base import PendingToolResult, Registry, Tool, ToolContext
9
9
  from .bash import Bash
10
10
  from .edit import Edit
11
11
  from .edit_mac import EditMac
12
- from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
12
+ from .filesystem import GlobTool, Grep, Read, Write
13
13
  from .glob_mac import GlobMac
14
14
  from .grep_mac import GrepMac
15
- from .lsp import LSP
16
15
  from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
17
16
  from .planexit import PlanExit
18
17
  from .question import Question
@@ -32,10 +31,7 @@ __all__ = [
32
31
  "GlobTool",
33
32
  "Grep",
34
33
  "GrepMac",
35
- "Insert",
36
- "LSP",
37
34
  "MCPTool",
38
- "Mkdir",
39
35
  "PlanExit",
40
36
  "Question",
41
37
  "Read",
@@ -60,14 +56,11 @@ def default_registry() -> Registry:
60
56
  for tool in (
61
57
  AgentTool(),
62
58
  TodoWrite(),
63
- LSP(),
64
59
  glob_tool,
65
60
  grep_tool,
66
61
  Read(),
67
- Insert(),
68
62
  edit_tool,
69
63
  Write(),
70
- Mkdir(),
71
64
  bash_tool,
72
65
  Skill(),
73
66
  Question(),
@@ -165,8 +165,7 @@ class ToolContext:
165
165
  def config_path(self) -> str | None:
166
166
  """Path to the active config file (None = default resolution).
167
167
 
168
- Tools that read the config file (e.g. the LSP tool, which loads
169
- per-extension server overrides) use this so a session started
168
+ Tools that read the config file use this so a session started
170
169
  with ``--config PATH`` reads the same file.
171
170
  """
172
171
  if self.session:
@@ -217,6 +217,8 @@ class Bash(Tool):
217
217
  - Commands that truly require shell execution
218
218
  - Running builds, tests, or development servers
219
219
  - System administration tasks
220
+ - Creating empty directories: use `mkdir -p <path>` (idempotent; `Write`
221
+ already creates parent directories for any file it writes)
220
222
 
221
223
  **When NOT to use `Bash`:**
222
224
  - File operations → use `Read`, `Write`, `Edit`, `Glob`, `Grep` instead
@@ -227,7 +227,7 @@ def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tu
227
227
  # realpath BEFORE writing (the same resolution diff_targets applies):
228
228
  # the write goes through atomic_write_text, whose os.replace would
229
229
  # otherwise overwrite a symlink with a regular file and leave the real
230
- # target untouched. Edit/Insert/Write resolve their own paths already.
230
+ # target untouched. Edit/Write resolve their own paths already.
231
231
  target = os.path.realpath(_resolve_target(section.new_path, cwd, fallback_path))
232
232
  if not os.path.isfile(target):
233
233
  return False, f"target file does not exist: {target}"