python-agent-harness 1.5.6.1__tar.gz → 1.5.6.3__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.6.3/PKG-INFO +239 -0
  2. python_agent_harness-1.5.6.3/README.md +205 -0
  3. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/agent.py +43 -4
  6. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/models.py +37 -5
  7. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/tool_runner.py +12 -2
  8. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/headless.py +34 -5
  9. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/server.py +24 -14
  10. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/io/persistence.py +10 -1
  11. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/client.py +22 -2
  12. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/client.py +209 -30
  13. python_agent_harness-1.5.6.3/python_agent_harness/prompts/agent.md +94 -0
  14. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/subagent.md +0 -2
  15. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/session/session.py +14 -10
  16. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/base.py +14 -0
  17. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/bash.py +7 -1
  18. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit.py +45 -13
  19. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob.py +2 -2
  20. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/glob_mac.py +2 -2
  21. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep.py +7 -2
  22. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/grep_mac.py +2 -2
  23. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/insert.py +2 -4
  24. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/lsp.py +2 -9
  25. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mkdir.py +4 -2
  26. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/read.py +2 -2
  27. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/write.py +5 -6
  28. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/commands.py +47 -36
  29. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/core.py +64 -19
  30. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/input.py +6 -4
  31. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/render.py +110 -20
  32. python_agent_harness-1.5.6.3/python_agent_harness.egg-info/PKG-INFO +239 -0
  33. python_agent_harness-1.5.6.1/PKG-INFO +0 -483
  34. python_agent_harness-1.5.6.1/README.md +0 -449
  35. python_agent_harness-1.5.6.1/python_agent_harness/prompts/agent.md +0 -113
  36. python_agent_harness-1.5.6.1/python_agent_harness.egg-info/PKG-INFO +0 -483
  37. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/LICENSE +0 -0
  38. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/__main__.py +0 -0
  39. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/__init__.py +0 -0
  40. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/context_manager.py +0 -0
  41. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/planmode.py +0 -0
  42. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/subagent.py +0 -0
  43. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/core/token_estimator.py +0 -0
  44. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/__init__.py +0 -0
  45. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/cli.py +0 -0
  46. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/controller.py +0 -0
  47. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/entry/view.py +0 -0
  48. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/io/__init__.py +0 -0
  49. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/io/attachments.py +0 -0
  50. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/io/diffrender.py +0 -0
  51. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/io/text_filter.py +0 -0
  52. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/llm/__init__.py +0 -0
  53. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/__init__.py +0 -0
  54. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/config.py +0 -0
  55. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/lsp/manager.py +0 -0
  56. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/__init__.py +0 -0
  57. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/client.py +0 -0
  58. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/config.py +0 -0
  59. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/mcp/manager.py +0 -0
  60. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/__init__.py +0 -0
  61. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  62. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/build-switch.md +0 -0
  63. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  64. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/compact.md +0 -0
  65. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/core.py +0 -0
  66. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/initialize.md +0 -0
  67. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  68. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/plan.md +0 -0
  69. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/review.md +0 -0
  70. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/summary.md +0 -0
  71. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  72. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/prompts/title.md +0 -0
  73. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/session/__init__.py +0 -0
  74. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/session/commands.py +0 -0
  75. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/session/config.py +0 -0
  76. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/__init__.py +0 -0
  77. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/agent_tool.py +0 -0
  78. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/diffapply.py +0 -0
  79. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/edit_mac.py +0 -0
  80. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/filesystem.py +0 -0
  81. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/mcp.py +0 -0
  82. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/planexit.py +0 -0
  83. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/question.py +0 -0
  84. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/skill.py +0 -0
  85. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tools/todo.py +0 -0
  86. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness/tui/__init__.py +0 -0
  87. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  88. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  89. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  90. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/requires.txt +0 -0
  91. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  92. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/setup.cfg +0 -0
  93. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/tests/test_invariants.py +0 -0
  94. {python_agent_harness-1.5.6.1 → python_agent_harness-1.5.6.3}/tests/test_scenarios.py +0 -0
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-agent-harness
3
+ Version: 1.5.6.3
4
+ Summary: A lightweight, hackable mini-OpenCode written in Python.
5
+ Author-email: Huming Chen <chenhuming@gmail.com>
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
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: rich>=13.0
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: certifi>=2024.0
23
+ Requires-Dist: prompt_toolkit>=3.0
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp<3,>=2.0; extra == "mcp"
26
+ Provides-Extra: dev
27
+ Requires-Dist: ruff>=0.6; extra == "dev"
28
+ Requires-Dist: pyright>=1.1.380; extra == "dev"
29
+ Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: coverage[toml]>=7.0; extra == "dev"
31
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
32
+ Requires-Dist: mcp<3,>=2.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ <div align="center">
36
+
37
+ # python-agent-harness
38
+
39
+ **A lightweight, hackable mini-OpenCode written in Python.**
40
+ FSM-driven execution · OpenAI-compatible · built for daily use and easy customization
41
+
42
+ [![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)
43
+ [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
44
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
45
+ [![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)
46
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
47
+
48
+ </div>
49
+
50
+ A terminal coding agent that reads your codebase, plans changes, edits files, runs commands, and verifies its work.
51
+
52
+ Inspired by [gptel-agent-harness](https://github.com/beacoder/gptel-agent-harness) and [opencode](https://github.com/anomalyco/opencode). It brings opencode's prompts and core behaviors — `AGENTS.md` discovery, plan/build modes, skills, sub-agents, todo tracking — into a lightweight Python implementation with only **three runtime dependencies**: `rich`, `httpx`, `prompt_toolkit`.
53
+
54
+ Works with any **OpenAI-compatible API**.
55
+
56
+ ## Demo
57
+
58
+ ![python-agent-harness demo](https://raw.githubusercontent.com/beacoder/python-agent-harness/main/demo.png)
59
+
60
+ ## Quick start
61
+
62
+ ```sh
63
+ pip install python-agent-harness # or: pip install -e ".[dev]" from source
64
+ python-agent-harness config --init
65
+ python-agent-harness run
66
+ ```
67
+
68
+ Optional extras: `[mcp]` for MCP server integration, `[dev]` for development tools.
69
+
70
+ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_key`, and `model`.
71
+
72
+ ## Features
73
+
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
+ - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
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.
78
+ - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
79
+ - **Persistent sessions** — auto-saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and `/restore --latest` / `/sessions`.
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.
81
+ - **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
82
+ - **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
83
+ - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
84
+ - **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
85
+
86
+ ## Usage
87
+
88
+ ### TUI mode
89
+
90
+ ```sh
91
+ python-agent-harness run [--project DIR]
92
+ ```
93
+
94
+ ### Headless mode
95
+
96
+ ```sh
97
+ python-agent-harness headless [prompt] [--project DIR] [--restore [SPEC]] [--model NAME] [--json]
98
+ ```
99
+
100
+ Runs a single prompt without the TUI — for CI, scripting, and piping. The assistant's answer goes to stdout; tool/status events go to stderr. Interactive prompts are auto-answered (`confirm` → yes, `ask` → "Unanswered").
101
+
102
+ - Prompt is a positional argument; when omitted, read from stdin.
103
+ - `--restore` continues a saved session. Bare `--restore` uses the most recent; a SPEC may be a file path or title substring.
104
+ - `--model NAME` selects a profile from the `models` config section, or a raw model name on the configured endpoint.
105
+ - `--json` emits JSON lines on stdout: `start`, `delta`, `notify`, `log`, and a final `result` (with structured `errors`). Each line carries `protocol` (wire schema version) and `seq` (monotonic counter). Exit code 0 on success, 1 on error.
106
+
107
+ ### Serve mode
108
+
109
+ ```sh
110
+ python-agent-harness serve [--project DIR] [--answer-timeout SECONDS] [--max-rounds N] [--timeout SECONDS]
111
+ ```
112
+
113
+ A persistent, bidirectional runtime for hosting applications. Keeps the `Controller`/`Session` resident so the host can submit multiple prompts, answer mid-run questions, and cancel runs via protocol messages. See [docs/serve-protocol.md](docs/serve-protocol.md) for the full protocol specification.
114
+
115
+ ### Slash commands
116
+
117
+ | Command | Description |
118
+ |---|---|
119
+ | `/plan` / `/build` | Switch between read-only plan mode and build mode |
120
+ | `/init` | Create or update `AGENTS.md` |
121
+ | `/review` | Review uncommitted changes, commits, branches, or pull requests |
122
+ | `/explain [project] [target]` | Explain code |
123
+ | `/compact` | Compact the conversation |
124
+ | `/summary` | Append a conversation summary |
125
+ | `/save` | Save the current session |
126
+ | `/sessions` | List saved sessions |
127
+ | `/restore [path\|title\|--latest\|latest]` | Restore a session (substring search) |
128
+ | `/clear` | Start a fresh conversation |
129
+ | `/model [name]` | Switch LLM profiles; `default` restores original |
130
+ | `/agent [name]` | Switch agent prompt; `default` restores built-in |
131
+ | `/help` | List available commands |
132
+ | `/exit` | Quit |
133
+
134
+ Custom commands from `prompts/commands/*.md` are also registered (TUI only).
135
+
136
+ ### Custom agents
137
+
138
+ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable profile via `/agent`. Optional YAML frontmatter:
139
+
140
+ - `name:` — override the agent name (defaults to file stem)
141
+ - `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
142
+
143
+ **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
144
+
145
+ ## Configuration
146
+
147
+ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environment variables are optional.
148
+
149
+ ```json
150
+ {
151
+ "llm": {
152
+ "base_url": "https://api.openai.com/v1",
153
+ "api_key": "sk-...",
154
+ "model": "gpt-5-mini",
155
+ "reasoning_effort": null,
156
+ "stream": true,
157
+ "supports_image_input": false
158
+ },
159
+ "models": {
160
+ "deepseek": { "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat" },
161
+ "qwen": { "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model": "qwen3.5-coder" }
162
+ },
163
+ "context_windows": {
164
+ "deepseek-v4": 1000000
165
+ },
166
+ "paths": {
167
+ "context_path": null,
168
+ "skill_path": null
169
+ },
170
+ "mcp": {
171
+ "servers": {
172
+ "example": {
173
+ "transport": "stdio",
174
+ "command": "npx",
175
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
176
+ "env": [],
177
+ "parallel": false,
178
+ "timeout": null,
179
+ "enabled": false
180
+ }
181
+ }
182
+ }
183
+ }
184
+ ```
185
+
186
+ ### Options
187
+
188
+ - **`llm`** — main LLM config. Optional keys: `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, `stream`, `supports_image_input`. `run --no-stream` overrides `stream`. `supports_image_input` (default `false`) controls whether `@path` images are sent or stripped.
189
+ - **`models`** — named LLM profiles for `/model` switching. Partial settings; unset keys inherit from `llm`. `default` restores the main config.
190
+ - **`context_windows`** — per-model context-window overrides (tokens). Keys are model names or substrings; first match wins. Overrides `CONTEXT_WINDOWS` in `config.py`.
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
+ - **`default_agent`** — agent name to use at session start (must exist in `prompts/agents/`). `null` = built-in default.
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
+ - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
196
+ - **`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
+ - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
198
+ - **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
199
+ - **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
200
+
201
+ ## Inspired by opencode
202
+
203
+ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
204
+
205
+ | opencode | python-agent-harness |
206
+ |---|---|
207
+ | `default.txt` (main agent) | `agent.md` |
208
+ | `plan.txt` / `plan-mode.txt` / `build-switch.txt` | `plan.md` / `plan-mode.md` / `build-switch.md` |
209
+ | `task.txt` (sub-agent) | `subagent.md` + `Agent` tool |
210
+ | `todowrite.txt` / `question.txt` / `skill.txt` | `TodoWrite` / `Question` / `Skill` tools |
211
+ | `read.txt` / `write.txt` / `edit.txt` / `grep.txt` / `glob.txt` | `Read` / `Write` / `Edit` / `Grep` / `Glob` tools |
212
+ | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
213
+ | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
214
+ | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
215
+ | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
216
+ | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
217
+
218
+ ## Development
219
+
220
+ Requires Python ≥ 3.11. CI runs against 3.11, 3.12, and 3.13 on Linux and macOS.
221
+
222
+ ```sh
223
+ make test # unit tests
224
+ venv/bin/pip install -e ".[dev]" # development tools
225
+ venv/bin/ruff check . # lint
226
+ venv/bin/ruff format --check . # formatting (CI blocks on this)
227
+ venv/bin/pyright # type checking
228
+ venv/bin/python -m build # build sdist + wheel
229
+ venv/bin/pip-audit # dependency audit
230
+ ```
231
+
232
+ ## Related projects
233
+
234
+ - [gptel-agent-harness](https://github.com/beacoder/gptel-agent-harness) — the Emacs-based implementation that inspired this project.
235
+ - [opencode](https://github.com/anomalyco/opencode) — the primary source of many prompts and coding-agent behaviors.
236
+
237
+ ## License
238
+
239
+ MIT
@@ -0,0 +1,205 @@
1
+ <div align="center">
2
+
3
+ # python-agent-harness
4
+
5
+ **A lightweight, hackable mini-OpenCode written in Python.**
6
+ FSM-driven execution · OpenAI-compatible · built for daily use and easy customization
7
+
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)
9
+ [![PyPI](https://img.shields.io/pypi/v/python-agent-harness.svg)](https://pypi.org/project/python-agent-harness/)
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)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
13
+
14
+ </div>
15
+
16
+ A terminal coding agent that reads your codebase, plans changes, edits files, runs commands, and verifies its work.
17
+
18
+ Inspired by [gptel-agent-harness](https://github.com/beacoder/gptel-agent-harness) and [opencode](https://github.com/anomalyco/opencode). It brings opencode's prompts and core behaviors — `AGENTS.md` discovery, plan/build modes, skills, sub-agents, todo tracking — into a lightweight Python implementation with only **three runtime dependencies**: `rich`, `httpx`, `prompt_toolkit`.
19
+
20
+ Works with any **OpenAI-compatible API**.
21
+
22
+ ## Demo
23
+
24
+ ![python-agent-harness demo](https://raw.githubusercontent.com/beacoder/python-agent-harness/main/demo.png)
25
+
26
+ ## Quick start
27
+
28
+ ```sh
29
+ pip install python-agent-harness # or: pip install -e ".[dev]" from source
30
+ python-agent-harness config --init
31
+ python-agent-harness run
32
+ ```
33
+
34
+ Optional extras: `[mcp]` for MCP server integration, `[dev]` for development tools.
35
+
36
+ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_key`, and `model`.
37
+
38
+ ## Features
39
+
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
+ - **Context management** — CJK-aware token estimation, per-model context windows, automatic compaction at 70% usage.
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.
44
+ - **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
45
+ - **Persistent sessions** — auto-saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and `/restore --latest` / `/sessions`.
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.
47
+ - **MCP support** — optional via `[mcp]` extra. MCP tools become ordinary agent tools (`mcp__<server>__<tool>`). Supports `stdio`, `streamable-http`, and `sse` transports.
48
+ - **Slash commands** — built-in `/init`, `/review`, `/explain`, etc., plus custom commands from `prompts/commands/*.md`.
49
+ - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent files live in `prompts/agents/*.md` and support `name:` and `exclude_tools:` frontmatter.
50
+ - **Embeddable runtime boundaries** — `headless --json` for one-shot CI/scripting; `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
51
+
52
+ ## Usage
53
+
54
+ ### TUI mode
55
+
56
+ ```sh
57
+ python-agent-harness run [--project DIR]
58
+ ```
59
+
60
+ ### Headless mode
61
+
62
+ ```sh
63
+ python-agent-harness headless [prompt] [--project DIR] [--restore [SPEC]] [--model NAME] [--json]
64
+ ```
65
+
66
+ Runs a single prompt without the TUI — for CI, scripting, and piping. The assistant's answer goes to stdout; tool/status events go to stderr. Interactive prompts are auto-answered (`confirm` → yes, `ask` → "Unanswered").
67
+
68
+ - Prompt is a positional argument; when omitted, read from stdin.
69
+ - `--restore` continues a saved session. Bare `--restore` uses the most recent; a SPEC may be a file path or title substring.
70
+ - `--model NAME` selects a profile from the `models` config section, or a raw model name on the configured endpoint.
71
+ - `--json` emits JSON lines on stdout: `start`, `delta`, `notify`, `log`, and a final `result` (with structured `errors`). Each line carries `protocol` (wire schema version) and `seq` (monotonic counter). Exit code 0 on success, 1 on error.
72
+
73
+ ### Serve mode
74
+
75
+ ```sh
76
+ python-agent-harness serve [--project DIR] [--answer-timeout SECONDS] [--max-rounds N] [--timeout SECONDS]
77
+ ```
78
+
79
+ A persistent, bidirectional runtime for hosting applications. Keeps the `Controller`/`Session` resident so the host can submit multiple prompts, answer mid-run questions, and cancel runs via protocol messages. See [docs/serve-protocol.md](docs/serve-protocol.md) for the full protocol specification.
80
+
81
+ ### Slash commands
82
+
83
+ | Command | Description |
84
+ |---|---|
85
+ | `/plan` / `/build` | Switch between read-only plan mode and build mode |
86
+ | `/init` | Create or update `AGENTS.md` |
87
+ | `/review` | Review uncommitted changes, commits, branches, or pull requests |
88
+ | `/explain [project] [target]` | Explain code |
89
+ | `/compact` | Compact the conversation |
90
+ | `/summary` | Append a conversation summary |
91
+ | `/save` | Save the current session |
92
+ | `/sessions` | List saved sessions |
93
+ | `/restore [path\|title\|--latest\|latest]` | Restore a session (substring search) |
94
+ | `/clear` | Start a fresh conversation |
95
+ | `/model [name]` | Switch LLM profiles; `default` restores original |
96
+ | `/agent [name]` | Switch agent prompt; `default` restores built-in |
97
+ | `/help` | List available commands |
98
+ | `/exit` | Quit |
99
+
100
+ Custom commands from `prompts/commands/*.md` are also registered (TUI only).
101
+
102
+ ### Custom agents
103
+
104
+ Agent files are markdown (`.md`) in `prompts/agents/`. Each becomes a switchable profile via `/agent`. Optional YAML frontmatter:
105
+
106
+ - `name:` — override the agent name (defaults to file stem)
107
+ - `exclude_tools:` — list of tool names to hide. Matches by exact name, glob (`mcp__git__*`), or `__`-delimited prefix (`mcp__git` hides `mcp__git__list_repos` but `Write` does NOT hide `TodoWrite`).
108
+
109
+ **Commands vs Agents:** Commands (`/review`) are one-shot with a hardcoded kickoff and reset after run. Agents (`/agent reviewer`) persist until the next `/agent`, let the user type their own prompt, and stay active after `Ctrl-C`.
110
+
111
+ ## Configuration
112
+
113
+ All LLM settings live in `~/.config/python-agent-harness/config.json`. Environment variables are optional.
114
+
115
+ ```json
116
+ {
117
+ "llm": {
118
+ "base_url": "https://api.openai.com/v1",
119
+ "api_key": "sk-...",
120
+ "model": "gpt-5-mini",
121
+ "reasoning_effort": null,
122
+ "stream": true,
123
+ "supports_image_input": false
124
+ },
125
+ "models": {
126
+ "deepseek": { "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat" },
127
+ "qwen": { "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "model": "qwen3.5-coder" }
128
+ },
129
+ "context_windows": {
130
+ "deepseek-v4": 1000000
131
+ },
132
+ "paths": {
133
+ "context_path": null,
134
+ "skill_path": null
135
+ },
136
+ "mcp": {
137
+ "servers": {
138
+ "example": {
139
+ "transport": "stdio",
140
+ "command": "npx",
141
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
142
+ "env": [],
143
+ "parallel": false,
144
+ "timeout": null,
145
+ "enabled": false
146
+ }
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ ### Options
153
+
154
+ - **`llm`** — main LLM config. Optional keys: `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, `stream`, `supports_image_input`. `run --no-stream` overrides `stream`. `supports_image_input` (default `false`) controls whether `@path` images are sent or stripped.
155
+ - **`models`** — named LLM profiles for `/model` switching. Partial settings; unset keys inherit from `llm`. `default` restores the main config.
156
+ - **`context_windows`** — per-model context-window overrides (tokens). Keys are model names or substrings; first match wins. Overrides `CONTEXT_WINDOWS` in `config.py`.
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
+ - **`default_agent`** — agent name to use at session start (must exist in `prompts/agents/`). `null` = built-in default.
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
+ - **`mcp.servers`** — MCP server config (requires `[mcp]` extra). Each supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, `enabled`.
162
+ - **`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
+ - **Precedence** — code defaults < config file < `OPENAI_*` env vars. Sub-agents also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
164
+ - **Custom config** — `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG` env var.
165
+ - **LLM logging** — request/response bodies logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change. Path printed at startup.
166
+
167
+ ## Inspired by opencode
168
+
169
+ Most of [opencode](https://github.com/anomalyco/opencode)'s prompts and core behaviors have been ported. The goal: retain its practical coding-agent workflow while keeping the implementation small and easy to customize.
170
+
171
+ | opencode | python-agent-harness |
172
+ |---|---|
173
+ | `default.txt` (main agent) | `agent.md` |
174
+ | `plan.txt` / `plan-mode.txt` / `build-switch.txt` | `plan.md` / `plan-mode.md` / `build-switch.md` |
175
+ | `task.txt` (sub-agent) | `subagent.md` + `Agent` tool |
176
+ | `todowrite.txt` / `question.txt` / `skill.txt` | `TodoWrite` / `Question` / `Skill` tools |
177
+ | `read.txt` / `write.txt` / `edit.txt` / `grep.txt` / `glob.txt` | `Read` / `Write` / `Edit` / `Grep` / `Glob` tools |
178
+ | `shell.txt` | `Bash` tool + `agent.md` Git/GitHub guidance |
179
+ | `plan-enter.txt` / `plan-exit.txt` | `PlanExit` tool |
180
+ | `initialize.txt` / `review.txt` / `explain` | `initialize.md` / `review.md` / `commands/explain.md` |
181
+ | compaction / summary / title | `compact.md` / `summary.md` / `title.md` |
182
+ | `AGENTS.md` handling | `prompts.py` (`find_agents_md_files`, `load_context_files`) |
183
+
184
+ ## Development
185
+
186
+ Requires Python ≥ 3.11. CI runs against 3.11, 3.12, and 3.13 on Linux and macOS.
187
+
188
+ ```sh
189
+ make test # unit tests
190
+ venv/bin/pip install -e ".[dev]" # development tools
191
+ venv/bin/ruff check . # lint
192
+ venv/bin/ruff format --check . # formatting (CI blocks on this)
193
+ venv/bin/pyright # type checking
194
+ venv/bin/python -m build # build sdist + wheel
195
+ venv/bin/pip-audit # dependency audit
196
+ ```
197
+
198
+ ## Related projects
199
+
200
+ - [gptel-agent-harness](https://github.com/beacoder/gptel-agent-harness) — the Emacs-based implementation that inspired this project.
201
+ - [opencode](https://github.com/anomalyco/opencode) — the primary source of many prompts and coding-agent behaviors.
202
+
203
+ ## License
204
+
205
+ MIT
@@ -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.1"
7
+ version = "1.5.6.3"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -8,7 +8,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
8
8
  from .mcp.manager import MCPManager
9
9
  from .session.session import Session
10
10
 
11
- __version__ = "1.5.6.1"
11
+ __version__ = "1.5.6.3"
12
12
 
13
13
  __all__ = [
14
14
  "Client",
@@ -137,6 +137,20 @@ class AgentLoop:
137
137
  self._local = threading.local()
138
138
  self.terminal_text: str | None = None
139
139
  self.result: str | None = None
140
+ # True right after an auto-compaction: the next WAIT must send
141
+ # the request instead of compacting again. Compaction keeps
142
+ # every user prompt verbatim, so when the irreducible part (the
143
+ # prompts, the system prompt, the tool specs) alone exceeds
144
+ # CONTEXT_TRIGGER, re-checking the ratio would compact forever
145
+ # -- one paid summary request per iteration.
146
+ self._just_compacted = False
147
+ # The context ratio measured right after a compaction that could
148
+ # NOT bring it below the trigger (None otherwise). While set,
149
+ # compacting again only pays off once the conversation has grown
150
+ # well past that floor; without it every tool round would be
151
+ # compacted again -- doubling the requests and summarizing each
152
+ # fresh tool result away before the model ever saw it.
153
+ self._compaction_floor: float | None = None
140
154
  # Cancellation identity for this run: cancel() bumps the session
141
155
  # generation, so a stale worker from a cancelled run stays
142
156
  # cancelled even after the next run clears the shared event (and
@@ -512,10 +526,35 @@ class AgentLoop:
512
526
  # so their ratio/usage would skew the parent's
513
527
  self._update_context_ratio()
514
528
  if self._need_compaction():
515
- session.log(f"compacting context {session.context_ratio:.1%}")
516
- if self.compact():
517
- self.info["compacted"] = True
518
- return
529
+ ratio = session.context_ratio
530
+ floor = self._compaction_floor
531
+ if self._just_compacted:
532
+ # Still above the trigger right after compacting:
533
+ # what is left is (mostly) irreducible. Send the
534
+ # request -- it either fits, or fails with a
535
+ # context-length error (ERRS) instead of looping --
536
+ # and remember the floor so later rounds do not
537
+ # compact again for nothing.
538
+ self._compaction_floor = ratio
539
+ session.log(
540
+ f"context {ratio:.1%} still above the compaction "
541
+ "trigger after compacting; sending anyway"
542
+ )
543
+ elif floor is not None and ratio <= floor + max(0.0, 1.0 - floor) / 2:
544
+ # Not yet grown by half the remaining headroom over
545
+ # the post-compaction floor (any growth at all once
546
+ # the floor is past the window): compacting now
547
+ # could only reclaim the latest rounds' output.
548
+ pass
549
+ else:
550
+ session.log(f"compacting context {ratio:.1%}")
551
+ if self.compact():
552
+ self._just_compacted = True
553
+ self.info["compacted"] = True
554
+ return
555
+ else:
556
+ self._compaction_floor = None
557
+ self._just_compacted = False
519
558
 
520
559
  def safe_delta(text: str) -> None:
521
560
  # Snapshot: a host that swaps views per run unwires
@@ -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
 
@@ -76,8 +76,18 @@ class ToolRunner:
76
76
  if isinstance(args, str):
77
77
  try:
78
78
  args = json.loads(args)
79
- except json.JSONDecodeError:
80
- args = {}
79
+ except json.JSONDecodeError as e:
80
+ # Do NOT fall back to {}: the required-argument check
81
+ # below would then report "missing required argument(s)"
82
+ # for arguments the model DID send -- typically a large
83
+ # Write/Edit cut off by max_tokens -- and the model tends
84
+ # to retry the identical oversized call.
85
+ return (
86
+ f"Error: {call.name} arguments are not valid JSON ({e.msg} at char "
87
+ f"{e.pos} of {len(args)}). If the arguments are large (e.g. a whole "
88
+ "file), the response was probably truncated by the output token "
89
+ "limit: split the change into smaller calls."
90
+ )
81
91
  if not isinstance(args, dict):
82
92
  args = {}
83
93
  # Validate required parameters from the tool schema