python-agent-harness 1.5.5.5__tar.gz → 1.5.5.6__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.
- {python_agent_harness-1.5.5.5/python_agent_harness.egg-info → python_agent_harness-1.5.5.6}/PKG-INFO +14 -2
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/README.md +13 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/pyproject.toml +1 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/__init__.py +7 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/agent.py +3 -2
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/cli.py +13 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/client.py +70 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/config.py +36 -0
- python_agent_harness-1.5.5.6/python_agent_harness/lsp/__init__.py +14 -0
- python_agent_harness-1.5.5.6/python_agent_harness/lsp/client.py +314 -0
- python_agent_harness-1.5.5.6/python_agent_harness/lsp/config.py +108 -0
- python_agent_harness-1.5.5.6/python_agent_harness/lsp/manager.py +117 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/agent.md +24 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/subagent.md +24 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/session.py +27 -18
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/subagent.py +2 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/__init__.py +3 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/base.py +82 -10
- python_agent_harness-1.5.5.6/python_agent_harness/tools/lsp.py +243 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6/python_agent_harness.egg-info}/PKG-INFO +14 -2
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness.egg-info/SOURCES.txt +6 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_cli.py +22 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_config.py +124 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_filesystem.py +39 -2
- python_agent_harness-1.5.5.6/tests/test_lsp.py +984 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_tools_misc.py +50 -1
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/LICENSE +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/commands.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/context_manager.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/diffrender.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/models.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/persistence.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/planmode.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/prompts.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/token_estimator.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tool_runner.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/bash_win.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/edit_win.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/glob.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/glob_win.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/grep.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/grep_mac.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/grep_win.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tui/commands.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/tui/render.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/setup.cfg +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_agents_md.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_client.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_commands.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_concurrent_subagents.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_context_rules.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_custom_agents.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_diffrender.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_invariants.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_mcp.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_models.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_persistence.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_planmode.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_prompts.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_scenarios.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_session.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_subagent.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_subagent_isolation.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_todos_scope.py +0 -0
- {python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/tests/test_token_estimator.py +0 -0
{python_agent_harness-1.5.5.5/python_agent_harness.egg-info → python_agent_harness-1.5.5.6}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.5.
|
|
3
|
+
Version: 1.5.5.6
|
|
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
|
|
@@ -105,7 +105,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
105
105
|
|
|
106
106
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early, while failed tool calls are sanitized so they never strand the agent. Transient API failures (`429` / `5xx`) retry with exponential backoff and jitter. Auth-expired status codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env — some API gateways return `502` instead of `401` when the backend auth token has expired. Note: codes in this list are treated as auth-expired exclusively and will not be retried with backoff, so only include codes that are unambiguously auth-related in your environment.
|
|
107
107
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
108
|
-
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
108
|
+
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
109
109
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
110
110
|
- **Persistent sessions** — sessions are automatically saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and support for `/restore --latest` and `/sessions`.
|
|
111
111
|
- **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
|
|
@@ -178,6 +178,16 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
178
178
|
"context_path": null,
|
|
179
179
|
"skill_path": null
|
|
180
180
|
},
|
|
181
|
+
"lsp": {
|
|
182
|
+
"servers": {
|
|
183
|
+
".c": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "c" },
|
|
184
|
+
".h": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "c" },
|
|
185
|
+
".cpp": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
186
|
+
".cc": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
187
|
+
".hpp": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
188
|
+
".cxx": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" }
|
|
189
|
+
}
|
|
190
|
+
},
|
|
181
191
|
"mcp": {
|
|
182
192
|
"servers": {
|
|
183
193
|
"example": {
|
|
@@ -206,6 +216,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
206
216
|
- **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
|
|
207
217
|
- **`default_agent`** — name of the agent to use at session start (instead of the built-in `agent.md`). The agent must exist as a `.md` file in the `prompts/agents/` directory. When unset or `null`, the built-in default agent is used. Use `/agent default` in the TUI to switch back to the built-in at any time.
|
|
208
218
|
- **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
|
|
219
|
+
- **`lsp.servers`** — optional per-extension LSP server overrides for the `LSP` code-intelligence tool. Keys are file extensions (e.g. `.py`, `.cpp`); each value has a `command` (the server argv) and an optional `language_id` (defaults to the extension without its dot). These layer on top of the built-in `DEFAULT_SERVERS` table in `lsp/manager.py`; an entry for an existing extension replaces its default. The server binary must be on `PATH`.
|
|
209
220
|
- **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
|
|
210
221
|
- **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
|
|
211
222
|
- **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
|
|
@@ -290,6 +301,7 @@ python_agent_harness/
|
|
|
290
301
|
├── cli.py # CLI entry points
|
|
291
302
|
├── tui/ # Rich + prompt_toolkit TUI (package)
|
|
292
303
|
├── diffrender.py # Unified diff generation + Rich rendering
|
|
304
|
+
├── lsp/ # Built-in LSP client
|
|
293
305
|
├── mcp/ # Optional MCP client
|
|
294
306
|
└── tools/ # Tool implementations + registry
|
|
295
307
|
```
|
|
@@ -71,7 +71,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
|
|
|
71
71
|
|
|
72
72
|
- **FSM-driven execution** — explicit `WAIT` / `TOOL` / `TRET` / `SUPERVISE` / `DONE` / `ERRS` / `ABRT` states. Completion supervision nudges the model when it stops early, while failed tool calls are sanitized so they never strand the agent. Transient API failures (`429` / `5xx`) retry with exponential backoff and jitter. Auth-expired status codes (configurable via `AUTH_REFRESH_STATUS_CODES` in `config.py`; defaults to `[401, 502]`) trigger automatic API key re-read from config/env — some API gateways return `502` instead of `401` when the backend auth token has expired. Note: codes in this list are treated as auth-expired exclusively and will not be retried with backoff, so only include codes that are unambiguously auth-related in your environment.
|
|
73
73
|
- **Context management** — CJK-aware token estimation, per-model context windows, and automatic compaction at 70% usage.
|
|
74
|
-
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
74
|
+
- **Coding tools** — `Agent`, `TodoWrite`, `Glob`, `Grep`, `Read`, `Insert`, `Edit` (including unified diffs), `Write`, `Mkdir`, `Bash`, `Skill`, `Question`, `LSP`, and `PlanExit`. Synchronous tools execute sequentially, but a round made up entirely of read-only tools (`Read`, `Glob`, `Grep`, `Skill`, `LSP`) is dispatched concurrently via a bounded thread pool; asynchronous tools such as `Bash` and `Agent` can run concurrently as well. Results are always delivered in the model's emitted order.
|
|
75
75
|
- **Plan / Build modes** — plan mode is read-only except for the per-session plan file.
|
|
76
76
|
- **Persistent sessions** — sessions are automatically saved after every response to `~/.local/share/python-agent-harness/sessions/`, with LLM-generated titles and support for `/restore --latest` and `/sessions`.
|
|
77
77
|
- **Focused TUI** — a Rich-based interface with a pinned status bar, Todos panel, inline red/green diff rendering for `Edit` and `Write`, and a `prompt_toolkit` editor with history and completion. `Esc+Enter` submits, `Ctrl-D` quits, and `Ctrl-C` cancels without leaving the application.
|
|
@@ -144,6 +144,16 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
144
144
|
"context_path": null,
|
|
145
145
|
"skill_path": null
|
|
146
146
|
},
|
|
147
|
+
"lsp": {
|
|
148
|
+
"servers": {
|
|
149
|
+
".c": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "c" },
|
|
150
|
+
".h": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "c" },
|
|
151
|
+
".cpp": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
152
|
+
".cc": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
153
|
+
".hpp": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" },
|
|
154
|
+
".cxx": { "command": ["clangd", "--compile-commands-dir=$REPO/build/Linux_x86_64", "--background-index"], "language_id": "cpp" }
|
|
155
|
+
}
|
|
156
|
+
},
|
|
147
157
|
"mcp": {
|
|
148
158
|
"servers": {
|
|
149
159
|
"example": {
|
|
@@ -172,6 +182,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
172
182
|
- **`subagent_llm`** — LLM configuration for `Agent` tool requests. Unset values inherit from the main `llm`. Set `profile` to reuse a profile from `models`. Precedence is: profile settings > explicit `subagent_llm` settings > main `llm` > environment variables.
|
|
173
183
|
- **`default_agent`** — name of the agent to use at session start (instead of the built-in `agent.md`). The agent must exist as a `.md` file in the `prompts/agents/` directory. When unset or `null`, the built-in default agent is used. Use `/agent default` in the TUI to switch back to the built-in at any time.
|
|
174
184
|
- **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
|
|
185
|
+
- **`lsp.servers`** — optional per-extension LSP server overrides for the `LSP` code-intelligence tool. Keys are file extensions (e.g. `.py`, `.cpp`); each value has a `command` (the server argv) and an optional `language_id` (defaults to the extension without its dot). These layer on top of the built-in `DEFAULT_SERVERS` table in `lsp/manager.py`; an entry for an existing extension replaces its default. The server binary must be on `PATH`.
|
|
175
186
|
- **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
|
|
176
187
|
- **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
|
|
177
188
|
- **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
|
|
@@ -256,6 +267,7 @@ python_agent_harness/
|
|
|
256
267
|
├── cli.py # CLI entry points
|
|
257
268
|
├── tui/ # Rich + prompt_toolkit TUI (package)
|
|
258
269
|
├── diffrender.py # Unified diff generation + Rich rendering
|
|
270
|
+
├── lsp/ # Built-in LSP client
|
|
259
271
|
├── mcp/ # Optional MCP client
|
|
260
272
|
└── tools/ # Tool implementations + registry
|
|
261
273
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-agent-harness"
|
|
7
|
-
version = "1.5.5.
|
|
7
|
+
version = "1.5.5.6"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
{python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/__init__.py
RENAMED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
"""python-agent-harness: a Python port of the gptel-agent-harness."""
|
|
2
2
|
|
|
3
|
+
from .client import Client, LLMClient
|
|
4
|
+
from .lsp.config import LSPConfig, LSPServerConfig
|
|
3
5
|
from .mcp.config import MCPConfig, MCPServerConfig
|
|
4
6
|
from .mcp.manager import MCPManager
|
|
5
7
|
from .models import AgentMode, Message, ToolCall, ToolSpec
|
|
6
8
|
from .session import Session
|
|
7
9
|
|
|
8
|
-
__version__ = "1.5.5.
|
|
10
|
+
__version__ = "1.5.5.6"
|
|
9
11
|
|
|
10
12
|
__all__ = [
|
|
13
|
+
"Client",
|
|
14
|
+
"LLMClient",
|
|
11
15
|
"Session",
|
|
12
16
|
"AgentMode",
|
|
17
|
+
"LSPConfig",
|
|
18
|
+
"LSPServerConfig",
|
|
13
19
|
"MCPConfig",
|
|
14
20
|
"MCPManager",
|
|
15
21
|
"MCPServerConfig",
|
|
@@ -38,6 +38,7 @@ from __future__ import annotations
|
|
|
38
38
|
from typing import Any, Protocol
|
|
39
39
|
|
|
40
40
|
from . import config
|
|
41
|
+
from .client import LLMClient
|
|
41
42
|
from .context_manager import ContextManager
|
|
42
43
|
from .models import Message, ToolCall
|
|
43
44
|
from .token_estimator import context_window_for, estimate_payload_tokens
|
|
@@ -74,7 +75,7 @@ class AgentLoop:
|
|
|
74
75
|
top_level: bool = True,
|
|
75
76
|
system: str | None = None,
|
|
76
77
|
max_rounds: int = 60,
|
|
77
|
-
client:
|
|
78
|
+
client: LLMClient | None = None,
|
|
78
79
|
) -> None:
|
|
79
80
|
self.session = session
|
|
80
81
|
self.messages: list[Message] = messages if messages is not None else []
|
|
@@ -631,7 +632,7 @@ def run_agent_loop(
|
|
|
631
632
|
top_level: bool = True,
|
|
632
633
|
system: str | None = None,
|
|
633
634
|
max_rounds: int = 60,
|
|
634
|
-
client:
|
|
635
|
+
client: LLMClient | None = None,
|
|
635
636
|
) -> str | None:
|
|
636
637
|
"""Convenience wrapper running a full agent run (FSM).
|
|
637
638
|
|
|
@@ -41,6 +41,11 @@ def make_session(
|
|
|
41
41
|
settings = config.load_llm_config(config_path)
|
|
42
42
|
paths = config.load_paths_config(config_path)
|
|
43
43
|
mcp_config = config.load_mcp_config(config_path)
|
|
44
|
+
# Validate lsp.servers eagerly so a malformed override fails fast at
|
|
45
|
+
# session start (with a clear message) instead of poisoning the first
|
|
46
|
+
# LSP tool call mid-session. The LSP manager re-reads it lazily via
|
|
47
|
+
# config_path; this call only surfaces config errors early.
|
|
48
|
+
config.load_lsp_config(config_path)
|
|
44
49
|
model = model or settings["model"]
|
|
45
50
|
# resolve sub-agent overrides against the EFFECTIVE main settings
|
|
46
51
|
# (so a CLI/caller model override is inherited too when the
|
|
@@ -243,6 +248,14 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
243
248
|
print(f" {pattern}: {size}")
|
|
244
249
|
else:
|
|
245
250
|
print("context_windows: (none configured — built-in table in config.py applies)")
|
|
251
|
+
# Show LSP server overrides (per-extension, layered over the built-ins)
|
|
252
|
+
lsp_config = config.load_lsp_config(args.path)
|
|
253
|
+
if lsp_config.servers:
|
|
254
|
+
print("lsp servers:")
|
|
255
|
+
for ext, server in sorted(lsp_config.servers.items()):
|
|
256
|
+
print(f" {ext}: language_id={server.language_id}, command={' '.join(server.command)}")
|
|
257
|
+
else:
|
|
258
|
+
print("lsp servers: (none configured — built-in DEFAULT_SERVERS in lsp/manager.py applies)")
|
|
246
259
|
# Show default agent
|
|
247
260
|
default_agent = config.load_default_agent(args.path)
|
|
248
261
|
print(f"default_agent: {default_agent or '(default: agent.md)'}")
|
{python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/client.py
RENAMED
|
@@ -19,13 +19,82 @@ import time
|
|
|
19
19
|
import uuid
|
|
20
20
|
from collections.abc import Callable, Iterator
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Any
|
|
22
|
+
from typing import Any, Protocol, runtime_checkable
|
|
23
23
|
|
|
24
24
|
import httpx
|
|
25
25
|
|
|
26
26
|
from . import config
|
|
27
27
|
from .models import Message, ToolCall, ToolSpec, Usage
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class LLMClient(Protocol):
|
|
32
|
+
"""Structural interface the agent loop needs from an LLM backend.
|
|
33
|
+
|
|
34
|
+
``AgentLoop`` and ``Session`` depend only on this surface, never on
|
|
35
|
+
the concrete ``Client`` transport. Anything providing these
|
|
36
|
+
members — the OpenAI-compatible ``Client`` below, a fake/replay
|
|
37
|
+
double in tests, or an alternative provider — can drive the FSM.
|
|
38
|
+
|
|
39
|
+
The members mirror how the agent consumes a client today:
|
|
40
|
+
|
|
41
|
+
- ``chat`` / ``chat_sync`` — the two request entry points (streaming
|
|
42
|
+
turns and one-shot compaction/title/summary calls).
|
|
43
|
+
- ``clone`` — a fresh, state-isolated copy for a concurrent
|
|
44
|
+
sub-agent (pools and the abort flag must never be shared).
|
|
45
|
+
- ``abort`` / ``close`` — cancel an in-flight request / release the
|
|
46
|
+
pool.
|
|
47
|
+
- ``set_timeout`` — apply a new request timeout (``/model`` switch).
|
|
48
|
+
- ``base_url`` / ``api_key`` / ``model`` / ``timeout`` — connection
|
|
49
|
+
settings the session reads and rewrites on a profile switch.
|
|
50
|
+
- ``context_window`` — the model's window (config-aware), read by
|
|
51
|
+
the context manager for compaction decisions.
|
|
52
|
+
- ``log_path`` — the shared LLM log file, inherited by clones.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
base_url: str
|
|
56
|
+
api_key: str | None
|
|
57
|
+
model: str
|
|
58
|
+
timeout: float
|
|
59
|
+
log_path: Path | None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def context_window(self) -> int: ...
|
|
63
|
+
|
|
64
|
+
def chat(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[Message],
|
|
67
|
+
tools: list[ToolSpec] | None = ...,
|
|
68
|
+
system: str | None = ...,
|
|
69
|
+
temperature: float | None = ...,
|
|
70
|
+
max_tokens: int | None = ...,
|
|
71
|
+
reasoning_effort: str | None = ...,
|
|
72
|
+
on_delta: Callable[[str], None] | None = ...,
|
|
73
|
+
on_tool_call: Callable[[str, str, str], None] | None = ...,
|
|
74
|
+
stream: bool = ...,
|
|
75
|
+
cancel_check: Callable[[], bool] | None = ...,
|
|
76
|
+
on_retry: Callable[[], None] | None = ...,
|
|
77
|
+
) -> tuple[Message, Usage]: ...
|
|
78
|
+
|
|
79
|
+
def chat_sync(
|
|
80
|
+
self,
|
|
81
|
+
messages: list[Message],
|
|
82
|
+
system: str | None = ...,
|
|
83
|
+
temperature: float | None = ...,
|
|
84
|
+
max_tokens: int | None = ...,
|
|
85
|
+
reasoning_effort: str | None = ...,
|
|
86
|
+
cancel_check: Callable[[], bool] | None = ...,
|
|
87
|
+
) -> tuple[Message, Usage]: ...
|
|
88
|
+
|
|
89
|
+
def clone(self) -> LLMClient: ...
|
|
90
|
+
|
|
91
|
+
def abort(self) -> None: ...
|
|
92
|
+
|
|
93
|
+
def close(self) -> None: ...
|
|
94
|
+
|
|
95
|
+
def set_timeout(self, timeout: float) -> None: ...
|
|
96
|
+
|
|
97
|
+
|
|
29
98
|
# serializes appends to the shared LLM log file: concurrent sub-agents
|
|
30
99
|
# (each with its own client but ONE shared log_path, see Client.clone)
|
|
31
100
|
# finish their interactions in parallel, and interleaved write() calls
|
{python_agent_harness-1.5.5.5 → python_agent_harness-1.5.5.6}/python_agent_harness/config.py
RENAMED
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from .lsp.config import LSPConfig
|
|
10
11
|
from .mcp.config import MCPConfig
|
|
11
12
|
|
|
12
13
|
# ---- context management -------------------------------------------------
|
|
@@ -98,6 +99,7 @@ DEFAULT_TOOLS: list[str] = [
|
|
|
98
99
|
"Bash",
|
|
99
100
|
"Skill",
|
|
100
101
|
"Question",
|
|
102
|
+
"LSP",
|
|
101
103
|
]
|
|
102
104
|
|
|
103
105
|
# ---- tool output limits --------------------------------------------------------
|
|
@@ -255,6 +257,15 @@ CONFIG_TEMPLATE = """\
|
|
|
255
257
|
"context_path": null,
|
|
256
258
|
"skill_path": null
|
|
257
259
|
}},
|
|
260
|
+
"lsp": {{
|
|
261
|
+
"_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.",
|
|
262
|
+
"servers": {{
|
|
263
|
+
".example": {{
|
|
264
|
+
"command": ["clangd", "--background-index", "--clang-tidy"],
|
|
265
|
+
"language_id": "cpp"
|
|
266
|
+
}}
|
|
267
|
+
}}
|
|
268
|
+
}},
|
|
258
269
|
"mcp": {{
|
|
259
270
|
"_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).",
|
|
260
271
|
"servers": {{
|
|
@@ -446,6 +457,31 @@ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
|
|
|
446
457
|
return MCPConfig.from_dict(section.get("servers"))
|
|
447
458
|
|
|
448
459
|
|
|
460
|
+
def load_lsp_config(path: str | os.PathLike | None = None) -> LSPConfig:
|
|
461
|
+
"""Load per-extension LSP server overrides from the config file.
|
|
462
|
+
|
|
463
|
+
Reads the optional ``lsp.servers`` object (keyed by file extension,
|
|
464
|
+
e.g. ``".py"``) and returns an ``LSPConfig`` (empty when the file
|
|
465
|
+
has no ``lsp`` section or no servers). These entries layer on top of
|
|
466
|
+
the built-in ``DEFAULT_SERVERS`` table in ``lsp/manager.py``.
|
|
467
|
+
|
|
468
|
+
Malformed entries raise ValueError so config errors surface at
|
|
469
|
+
session start rather than the first LSP call.
|
|
470
|
+
"""
|
|
471
|
+
data = _read_config(path)
|
|
472
|
+
section = data.get("lsp") or {}
|
|
473
|
+
if not isinstance(section, dict):
|
|
474
|
+
raise ValueError(f"config file {_config_path(path)}: lsp must be an object")
|
|
475
|
+
servers = section.get("servers") or {}
|
|
476
|
+
if not isinstance(servers, dict):
|
|
477
|
+
raise ValueError(f"config file {_config_path(path)}: lsp.servers must be an object")
|
|
478
|
+
try:
|
|
479
|
+
return LSPConfig.from_dict(servers)
|
|
480
|
+
except ValueError as e:
|
|
481
|
+
# Re-raise with the config-file path for a clearer message.
|
|
482
|
+
raise ValueError(f"config file {_config_path(path)}: {e}") from e
|
|
483
|
+
|
|
484
|
+
|
|
449
485
|
def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]:
|
|
450
486
|
"""Load named LLM profiles from the config file's ``models`` object.
|
|
451
487
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Minimal built-in LSP client used by python-agent-harness."""
|
|
2
|
+
|
|
3
|
+
from .client import LSPClient, LSPError
|
|
4
|
+
from .config import LSPConfig, LSPServerConfig
|
|
5
|
+
from .manager import get_client, shutdown_all
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"LSPClient",
|
|
9
|
+
"LSPConfig",
|
|
10
|
+
"LSPError",
|
|
11
|
+
"LSPServerConfig",
|
|
12
|
+
"get_client",
|
|
13
|
+
"shutdown_all",
|
|
14
|
+
]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Small, dependency-free LSP/JSON-RPC client used by the agent harness.
|
|
2
|
+
|
|
3
|
+
This intentionally implements only the transport/lifecycle pieces needed by the
|
|
4
|
+
agent-facing LSP tool. It is synchronous at the public boundary because the
|
|
5
|
+
harness' Tool API is synchronous; a reader thread keeps server messages flowing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LSPError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LSPClient:
|
|
25
|
+
def __init__(self, command: list[str], root: str, language_id: str, timeout: float = 30.0):
|
|
26
|
+
self.command = command
|
|
27
|
+
self.root = os.path.realpath(root)
|
|
28
|
+
self.language_id = language_id
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
self.proc: subprocess.Popen[bytes] | None = None
|
|
31
|
+
self._reader: threading.Thread | None = None
|
|
32
|
+
self._write_lock = threading.Lock()
|
|
33
|
+
self._state_lock = threading.Lock()
|
|
34
|
+
self._next_id = 1
|
|
35
|
+
self._pending: dict[int, queue.Queue] = {}
|
|
36
|
+
self._opened: dict[str, int] = {}
|
|
37
|
+
self._texts: dict[str, str] = {}
|
|
38
|
+
self._diagnostics: dict[str, list[dict[str, Any]]] = {}
|
|
39
|
+
self._capabilities: dict[str, Any] = {}
|
|
40
|
+
self.position_encoding = "utf-16"
|
|
41
|
+
self._closed = False
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def alive(self) -> bool:
|
|
45
|
+
return self.proc is not None and self.proc.poll() is None and not self._closed
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
if self.alive:
|
|
49
|
+
return
|
|
50
|
+
self._closed = False
|
|
51
|
+
try:
|
|
52
|
+
self.proc = subprocess.Popen(
|
|
53
|
+
self.command,
|
|
54
|
+
stdin=subprocess.PIPE,
|
|
55
|
+
stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.DEVNULL,
|
|
57
|
+
cwd=self.root,
|
|
58
|
+
bufsize=0,
|
|
59
|
+
)
|
|
60
|
+
except OSError as e:
|
|
61
|
+
raise LSPError(f"failed to start LSP server {' '.join(self.command)!r}: {e}") from e
|
|
62
|
+
self._reader = threading.Thread(
|
|
63
|
+
target=self._read_loop, daemon=True, name=f"lsp-reader:{self.command[0]}"
|
|
64
|
+
)
|
|
65
|
+
self._reader.start()
|
|
66
|
+
try:
|
|
67
|
+
result = self.request(
|
|
68
|
+
"initialize",
|
|
69
|
+
{
|
|
70
|
+
"processId": os.getpid(),
|
|
71
|
+
"rootUri": Path(self.root).as_uri(),
|
|
72
|
+
"workspaceFolders": [
|
|
73
|
+
{"uri": Path(self.root).as_uri(), "name": Path(self.root).name}
|
|
74
|
+
],
|
|
75
|
+
"capabilities": {
|
|
76
|
+
"general": {"positionEncodings": ["utf-16", "utf-8"]},
|
|
77
|
+
"workspace": {
|
|
78
|
+
"workspaceFolders": True,
|
|
79
|
+
"symbol": {"dynamicRegistration": False},
|
|
80
|
+
},
|
|
81
|
+
"textDocument": {
|
|
82
|
+
"definition": {"linkSupport": True},
|
|
83
|
+
"references": {},
|
|
84
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
85
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
86
|
+
"implementation": {"linkSupport": True},
|
|
87
|
+
"callHierarchy": {"dynamicRegistration": False},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"trace": "off",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
self._capabilities = result.get("capabilities", {}) if isinstance(result, dict) else {}
|
|
94
|
+
encoding = self._capabilities.get("positionEncoding")
|
|
95
|
+
if encoding in {"utf-8", "utf-16", "utf-32"}:
|
|
96
|
+
self.position_encoding = encoding
|
|
97
|
+
self.notify("initialized", {})
|
|
98
|
+
except Exception:
|
|
99
|
+
self.close()
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
def request(self, method: str, params: Any = None) -> Any:
|
|
103
|
+
self.start_if_needed()
|
|
104
|
+
with self._state_lock:
|
|
105
|
+
request_id = self._next_id
|
|
106
|
+
self._next_id += 1
|
|
107
|
+
waiter: queue.Queue = queue.Queue(maxsize=1)
|
|
108
|
+
self._pending[request_id] = waiter
|
|
109
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
|
110
|
+
if params is not None:
|
|
111
|
+
message["params"] = params
|
|
112
|
+
try:
|
|
113
|
+
self._send(message)
|
|
114
|
+
kind, value = waiter.get(timeout=self.timeout)
|
|
115
|
+
except queue.Empty as e:
|
|
116
|
+
with self._state_lock:
|
|
117
|
+
self._pending.pop(request_id, None)
|
|
118
|
+
raise LSPError(f"LSP request timed out: {method}") from e
|
|
119
|
+
except BaseException:
|
|
120
|
+
# _send() may raise (server down / write failure); drop the
|
|
121
|
+
# now-orphaned pending entry so it does not leak forever.
|
|
122
|
+
with self._state_lock:
|
|
123
|
+
self._pending.pop(request_id, None)
|
|
124
|
+
raise
|
|
125
|
+
if kind == "error":
|
|
126
|
+
code = value.get("code") if isinstance(value, dict) else None
|
|
127
|
+
msg = (
|
|
128
|
+
value.get("message", "unknown LSP error") if isinstance(value, dict) else str(value)
|
|
129
|
+
)
|
|
130
|
+
raise LSPError(f"LSP {method} failed ({code}): {msg}")
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
def notify(self, method: str, params: Any = None) -> None:
|
|
134
|
+
self.start_if_needed()
|
|
135
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
136
|
+
if params is not None:
|
|
137
|
+
message["params"] = params
|
|
138
|
+
self._send(message)
|
|
139
|
+
|
|
140
|
+
def start_if_needed(self) -> None:
|
|
141
|
+
if not self.alive:
|
|
142
|
+
self.start()
|
|
143
|
+
|
|
144
|
+
def open_document(self, uri: str, text: str) -> None:
|
|
145
|
+
self.start_if_needed()
|
|
146
|
+
# Decide which notification to send while holding _state_lock, but
|
|
147
|
+
# send it AFTER releasing the lock: notify()->_send() does a blocking
|
|
148
|
+
# write on the server's stdin pipe, and holding _state_lock across that
|
|
149
|
+
# write can deadlock the reader thread (which needs _state_lock to
|
|
150
|
+
# deliver responses/diagnostics) if the pipe buffer fills.
|
|
151
|
+
with self._state_lock:
|
|
152
|
+
version = self._opened.get(uri, 0)
|
|
153
|
+
if version:
|
|
154
|
+
if self._texts.get(uri) == text:
|
|
155
|
+
return
|
|
156
|
+
version += 1
|
|
157
|
+
self._opened[uri] = version
|
|
158
|
+
self._texts[uri] = text
|
|
159
|
+
method = "textDocument/didChange"
|
|
160
|
+
params: dict[str, Any] = {
|
|
161
|
+
"textDocument": {"uri": uri, "version": version},
|
|
162
|
+
"contentChanges": [{"text": text}],
|
|
163
|
+
}
|
|
164
|
+
else:
|
|
165
|
+
self._opened[uri] = 1
|
|
166
|
+
self._texts[uri] = text
|
|
167
|
+
method = "textDocument/didOpen"
|
|
168
|
+
params = {
|
|
169
|
+
"textDocument": {
|
|
170
|
+
"uri": uri,
|
|
171
|
+
"languageId": self.language_id,
|
|
172
|
+
"version": 1,
|
|
173
|
+
"text": text,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
self.notify(method, params)
|
|
177
|
+
|
|
178
|
+
def close_document(self, uri: str) -> None:
|
|
179
|
+
with self._state_lock:
|
|
180
|
+
if uri not in self._opened:
|
|
181
|
+
return
|
|
182
|
+
self._opened.pop(uri, None)
|
|
183
|
+
self._texts.pop(uri, None)
|
|
184
|
+
if self.alive:
|
|
185
|
+
self.notify("textDocument/didClose", {"textDocument": {"uri": uri}})
|
|
186
|
+
|
|
187
|
+
def diagnostics(self, uri: str) -> list[dict[str, Any]]:
|
|
188
|
+
with self._state_lock:
|
|
189
|
+
return list(self._diagnostics.get(uri, []))
|
|
190
|
+
|
|
191
|
+
def close(self) -> None:
|
|
192
|
+
if self._closed:
|
|
193
|
+
return
|
|
194
|
+
proc = self.proc
|
|
195
|
+
was_alive = proc is not None and proc.poll() is None
|
|
196
|
+
if was_alive:
|
|
197
|
+
with contextlib.suppress(Exception):
|
|
198
|
+
self.request("shutdown", None)
|
|
199
|
+
with contextlib.suppress(Exception):
|
|
200
|
+
self.notify("exit", None)
|
|
201
|
+
self._closed = True
|
|
202
|
+
proc = self.proc
|
|
203
|
+
if proc is not None and proc.poll() is None:
|
|
204
|
+
with contextlib.suppress(Exception):
|
|
205
|
+
proc.terminate()
|
|
206
|
+
proc.wait(timeout=2)
|
|
207
|
+
with contextlib.suppress(Exception):
|
|
208
|
+
proc.kill()
|
|
209
|
+
# Close the pipe file objects so their file descriptors are
|
|
210
|
+
# released (subprocess does not close them on terminate/kill —
|
|
211
|
+
# leaving them open leaks fds and emits ResourceWarning).
|
|
212
|
+
if proc is not None:
|
|
213
|
+
for stream in (proc.stdin, proc.stdout):
|
|
214
|
+
if stream is not None:
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
stream.close()
|
|
217
|
+
with self._state_lock:
|
|
218
|
+
with contextlib.suppress(queue.Full):
|
|
219
|
+
for waiter in self._pending.values():
|
|
220
|
+
waiter.put_nowait(("error", {"message": "LSP client closed"}))
|
|
221
|
+
self._pending.clear()
|
|
222
|
+
self._opened.clear()
|
|
223
|
+
self._texts.clear()
|
|
224
|
+
|
|
225
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
226
|
+
data = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
227
|
+
packet = b"Content-Length: " + str(len(data)).encode("ascii") + b"\r\n\r\n" + data
|
|
228
|
+
proc = self.proc
|
|
229
|
+
if proc is None or proc.stdin is None or proc.poll() is not None:
|
|
230
|
+
raise LSPError("LSP server is not running")
|
|
231
|
+
with self._write_lock:
|
|
232
|
+
try:
|
|
233
|
+
proc.stdin.write(packet)
|
|
234
|
+
proc.stdin.flush()
|
|
235
|
+
except OSError as e:
|
|
236
|
+
raise LSPError(f"failed writing to LSP server: {e}") from e
|
|
237
|
+
|
|
238
|
+
def _read_loop(self) -> None:
|
|
239
|
+
try:
|
|
240
|
+
proc = self.proc
|
|
241
|
+
if proc is None or proc.stdout is None:
|
|
242
|
+
return
|
|
243
|
+
stream = proc.stdout
|
|
244
|
+
while not self._closed:
|
|
245
|
+
headers: dict[str, str] = {}
|
|
246
|
+
while True:
|
|
247
|
+
line = stream.readline()
|
|
248
|
+
if not line:
|
|
249
|
+
return
|
|
250
|
+
line = line.rstrip(b"\r\n")
|
|
251
|
+
if not line:
|
|
252
|
+
break
|
|
253
|
+
if b":" in line:
|
|
254
|
+
key, value = line.split(b":", 1)
|
|
255
|
+
headers[key.decode("ascii", "replace").lower()] = value.strip().decode(
|
|
256
|
+
"ascii", "replace"
|
|
257
|
+
)
|
|
258
|
+
try:
|
|
259
|
+
length = int(headers.get("content-length", "0"))
|
|
260
|
+
except ValueError:
|
|
261
|
+
continue
|
|
262
|
+
payload = stream.read(length)
|
|
263
|
+
if len(payload) != length:
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
message = json.loads(payload.decode("utf-8"))
|
|
267
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
268
|
+
continue
|
|
269
|
+
self._handle_message(message)
|
|
270
|
+
finally:
|
|
271
|
+
with self._state_lock:
|
|
272
|
+
pending = list(self._pending.values())
|
|
273
|
+
self._pending.clear()
|
|
274
|
+
with contextlib.suppress(queue.Full):
|
|
275
|
+
for waiter in pending:
|
|
276
|
+
waiter.put_nowait(("error", {"message": "LSP server exited"}))
|
|
277
|
+
|
|
278
|
+
def _handle_message(self, message: dict[str, Any]) -> None:
|
|
279
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
280
|
+
request_id = message.get("id")
|
|
281
|
+
if isinstance(request_id, int):
|
|
282
|
+
with self._state_lock:
|
|
283
|
+
waiter = self._pending.pop(request_id, None)
|
|
284
|
+
if waiter is not None:
|
|
285
|
+
waiter.put(
|
|
286
|
+
("error", message["error"])
|
|
287
|
+
if "error" in message
|
|
288
|
+
else ("result", message.get("result"))
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
method = message.get("method")
|
|
293
|
+
params = message.get("params")
|
|
294
|
+
if method == "textDocument/publishDiagnostics" and isinstance(params, dict):
|
|
295
|
+
uri = params.get("uri")
|
|
296
|
+
if isinstance(uri, str):
|
|
297
|
+
with self._state_lock:
|
|
298
|
+
self._diagnostics[uri] = params.get("diagnostics", []) or []
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Servers may send requests/notifications to the client. Respond to
|
|
302
|
+
# common client-side requests so a server does not stall waiting for
|
|
303
|
+
# configuration/progress/UI handling that the coding agent does not need.
|
|
304
|
+
if "id" in message and isinstance(message.get("id"), int):
|
|
305
|
+
method = str(method or "")
|
|
306
|
+
if method == "workspace/configuration":
|
|
307
|
+
items = params if isinstance(params, list) else []
|
|
308
|
+
result = [None for _ in items]
|
|
309
|
+
elif method == "workspace/applyEdit":
|
|
310
|
+
result = {"applied": False}
|
|
311
|
+
else:
|
|
312
|
+
result = None
|
|
313
|
+
with contextlib.suppress(LSPError):
|
|
314
|
+
self._send({"jsonrpc": "2.0", "id": message["id"], "result": result})
|