python-agent-harness 1.5.4.1__tar.gz → 1.5.4.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.
- {python_agent_harness-1.5.4.1/python_agent_harness.egg-info → python_agent_harness-1.5.4.3}/PKG-INFO +5 -5
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/README.md +4 -4
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/pyproject.toml +1 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/client.py +11 -15
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/config.py +22 -33
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/context_manager.py +6 -6
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/manager.py +5 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/planmode.py +5 -2
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/session.py +3 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/token_estimator.py +9 -3
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/__init__.py +11 -2
- python_agent_harness-1.5.4.3/python_agent_harness/tools/diffapply.py +246 -0
- python_agent_harness-1.5.4.3/python_agent_harness/tools/edit_mac.py +56 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/glob.py +4 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/grep.py +1 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3/python_agent_harness.egg-info}/PKG-INFO +5 -5
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/SOURCES.txt +2 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_agents_md.py +5 -5
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_client.py +47 -17
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_config.py +39 -8
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_filesystem.py +179 -13
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_invariants.py +26 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_prompts.py +2 -2
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_session.py +47 -4
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_token_estimator.py +14 -1
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/LICENSE +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/agent.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/cli.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/commands.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/diffrender.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/models.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/persistence.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/subagent.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tool_runner.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/commands.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/render.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/setup.cfg +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_cli.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_commands.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_concurrent_subagents.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_context_rules.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_diffrender.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_mcp.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_models.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_persistence.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_planmode.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_scenarios.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_subagent.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_subagent_isolation.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_todos_scope.py +0 -0
- {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_tools_misc.py +0 -0
{python_agent_harness-1.5.4.1/python_agent_harness.egg-info → python_agent_harness-1.5.4.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.4.
|
|
3
|
+
Version: 1.5.4.3
|
|
4
4
|
Summary: A lightweight, hackable mini-OpenCode written in Python.
|
|
5
5
|
Author: huming
|
|
6
6
|
License-Expression: MIT
|
|
@@ -146,8 +146,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
146
146
|
}
|
|
147
147
|
},
|
|
148
148
|
"context_windows": {
|
|
149
|
-
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or
|
|
150
|
-
"deepseek-v4
|
|
149
|
+
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g. deepseek-v4 = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py.",
|
|
150
|
+
"deepseek-v4": 1000000
|
|
151
151
|
},
|
|
152
152
|
"subagent_llm": {
|
|
153
153
|
"profile": null,
|
|
@@ -188,7 +188,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
188
188
|
|
|
189
189
|
- **`llm`** — main LLM configuration. Optional keys include `backend`, `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
|
|
190
190
|
- **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
|
|
191
|
-
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or
|
|
191
|
+
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
|
|
192
192
|
- **`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.
|
|
193
193
|
- **`paths.context_path` / `paths.skill_path`** — locations from which to load context files and skills. When unset, the project-local `<project>/contexts` and `<project>/skills` directories are used.
|
|
194
194
|
- **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
|
|
@@ -248,7 +248,7 @@ python_agent_harness/
|
|
|
248
248
|
|
|
249
249
|
## Development
|
|
250
250
|
|
|
251
|
-
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13.
|
|
251
|
+
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
|
|
252
252
|
|
|
253
253
|
```sh
|
|
254
254
|
make test # unit tests
|
|
@@ -123,8 +123,8 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
123
123
|
}
|
|
124
124
|
},
|
|
125
125
|
"context_windows": {
|
|
126
|
-
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or
|
|
127
|
-
"deepseek-v4
|
|
126
|
+
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g. deepseek-v4 = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py.",
|
|
127
|
+
"deepseek-v4": 1000000
|
|
128
128
|
},
|
|
129
129
|
"subagent_llm": {
|
|
130
130
|
"profile": null,
|
|
@@ -165,7 +165,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
|
|
|
165
165
|
|
|
166
166
|
- **`llm`** — main LLM configuration. Optional keys include `backend`, `temperature`, `max_tokens`, `timeout`, `reasoning_effort`, and `stream`. Values such as `reasoning_effort` are passed to the API as-is when set. `run --no-stream` overrides `stream`.
|
|
167
167
|
- **`models`** — named LLM profiles for runtime switching with `/model`. A profile is a partial settings dictionary; unset keys inherit from the main `llm`. `default` restores the main LLM configuration.
|
|
168
|
-
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or
|
|
168
|
+
- **`context_windows`** — optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
|
|
169
169
|
- **`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.
|
|
170
170
|
- **`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.
|
|
171
171
|
- **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
|
|
@@ -225,7 +225,7 @@ python_agent_harness/
|
|
|
225
225
|
|
|
226
226
|
## Development
|
|
227
227
|
|
|
228
|
-
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13.
|
|
228
|
+
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and macOS.
|
|
229
229
|
|
|
230
230
|
```sh
|
|
231
231
|
make test # unit tests
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/client.py
RENAMED
|
@@ -200,7 +200,7 @@ class Client:
|
|
|
200
200
|
) -> None:
|
|
201
201
|
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
|
|
202
202
|
self.api_key = api_key or _default_api_key()
|
|
203
|
-
self.model = model or config.DEFAULT_MODEL
|
|
203
|
+
self.model: str = model or config.DEFAULT_MODEL
|
|
204
204
|
self.timeout = timeout
|
|
205
205
|
self.verify = verify if verify is not None else _resolve_ca_bundle()
|
|
206
206
|
self.retry_max = config.API_RETRY_MAX if retry_max is None else retry_max
|
|
@@ -212,8 +212,6 @@ class Client:
|
|
|
212
212
|
)
|
|
213
213
|
self._config_path = config_path
|
|
214
214
|
self._http = httpx.Client(timeout=timeout, verify=self.verify)
|
|
215
|
-
# Resolve context window for this model (API discovery + fallbacks)
|
|
216
|
-
self._context_window = None # lazy-loaded
|
|
217
215
|
# True while the in-flight request was aborted (Ctrl-C): a
|
|
218
216
|
# connection error on an aborted request must NOT be retried —
|
|
219
217
|
# the user asked to stop. Cleared at the start of each chat()
|
|
@@ -233,19 +231,17 @@ class Client:
|
|
|
233
231
|
|
|
234
232
|
Resolution order: config-file ``context_windows`` overrides
|
|
235
233
|
(via ``config.get_context_window_for_model``) -> CONTEXT_WINDOWS
|
|
236
|
-
pattern match -> DEFAULT_CONTEXT_WINDOW.
|
|
237
|
-
|
|
234
|
+
pattern match -> DEFAULT_CONTEXT_WINDOW. Resolved on every
|
|
235
|
+
access (no caching), so a runtime model switch or config-file
|
|
236
|
+
edit takes effect immediately; a malformed config falls back to
|
|
237
|
+
the default for that access and recovers once the file is fixed.
|
|
238
238
|
"""
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
# a malformed context_windows section must not break
|
|
246
|
-
# the loop: cache the safe default
|
|
247
|
-
self._context_window = config.DEFAULT_CONTEXT_WINDOW
|
|
248
|
-
return self._context_window
|
|
239
|
+
try:
|
|
240
|
+
return config.get_context_window_for_model(self.model, config_path=self._config_path)
|
|
241
|
+
except Exception:
|
|
242
|
+
# a malformed context_windows section must not break the
|
|
243
|
+
# loop: use the safe default, retry on the next access
|
|
244
|
+
return config.DEFAULT_CONTEXT_WINDOW
|
|
249
245
|
|
|
250
246
|
def close(self) -> None:
|
|
251
247
|
self._http.close()
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/config.py
RENAMED
|
@@ -5,7 +5,6 @@ Mirrors the defcustom defaults of the Emacs gptel-agent-harness.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
import fnmatch
|
|
9
8
|
import json
|
|
10
9
|
import os
|
|
11
10
|
from pathlib import Path
|
|
@@ -16,32 +15,22 @@ from .mcp.config import MCPConfig
|
|
|
16
15
|
CONTEXT_TRIGGER = 0.70
|
|
17
16
|
|
|
18
17
|
# Entries are matched in order (first match wins): put more specific
|
|
19
|
-
# patterns before general ones.
|
|
20
|
-
# on explicit entries preserves prefix matching for suffixed model
|
|
21
|
-
# names (e.g. "deepseek-v4-flash" -> deepseek-v4*), matching the
|
|
22
|
-
# legacy substring behavior.
|
|
18
|
+
# patterns before general ones.
|
|
23
19
|
CONTEXT_WINDOWS: list[tuple[str, int]] = [
|
|
24
|
-
("gpt-5-mini
|
|
25
|
-
("gpt-5
|
|
26
|
-
("gpt-oss-120b
|
|
27
|
-
("claude
|
|
28
|
-
("deepseek-v3
|
|
29
|
-
("deepseek-v4
|
|
30
|
-
("qwen3.5
|
|
31
|
-
("qwen3.6
|
|
32
|
-
("qwen3.8
|
|
33
|
-
("qwen3
|
|
34
|
-
("glm-5.2
|
|
35
|
-
("glm-5.1
|
|
36
|
-
("kimi-k2.7
|
|
37
|
-
("kimi
|
|
38
|
-
# Wildcard fallbacks for unknown models
|
|
39
|
-
("gpt-*", 128_000),
|
|
40
|
-
("claude-*", 200_000),
|
|
41
|
-
("deepseek-*", 128_000),
|
|
42
|
-
("qwen-*", 128_000),
|
|
43
|
-
("glm-*", 128_000),
|
|
44
|
-
("kimi-*", 128_000),
|
|
20
|
+
("gpt-5-mini", 128_000),
|
|
21
|
+
("gpt-5", 400_000),
|
|
22
|
+
("gpt-oss-120b", 128_000),
|
|
23
|
+
("claude", 200_000),
|
|
24
|
+
("deepseek-v3", 128_000),
|
|
25
|
+
("deepseek-v4", 1_000_000),
|
|
26
|
+
("qwen3.5", 131_072),
|
|
27
|
+
("qwen3.6", 262_144),
|
|
28
|
+
("qwen3.8", 262_144),
|
|
29
|
+
("qwen3", 131_072),
|
|
30
|
+
("glm-5.2", 1_000_000),
|
|
31
|
+
("glm-5.1", 128_000),
|
|
32
|
+
("kimi-k2.7", 256_000),
|
|
33
|
+
("kimi", 128_000),
|
|
45
34
|
]
|
|
46
35
|
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
47
36
|
|
|
@@ -233,8 +222,8 @@ CONFIG_TEMPLATE = """\
|
|
|
233
222
|
}}
|
|
234
223
|
}},
|
|
235
224
|
"context_windows": {{
|
|
236
|
-
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or
|
|
237
|
-
"deepseek-v4
|
|
225
|
+
"_comment": "Optional per-model context-window overrides (tokens). Keys are model names or substrings (e.g. deepseek-v4 = 1000000); matched in file order, first match wins. Overrides the built-in CONTEXT_WINDOWS table in config.py. Remove this section to use the built-in table.",
|
|
226
|
+
"deepseek-v4": 1000000
|
|
238
227
|
}},
|
|
239
228
|
"subagent_llm": {{
|
|
240
229
|
"_comment": "Optional overrides for sub-agent (Agent tool) requests, e.g. a cheaper model. Every key is optional; unset keys inherit the main llm settings above. Set 'profile' to a name from the 'models' section to reuse a model profile (profile settings win over explicit keys below).",
|
|
@@ -476,7 +465,7 @@ def mask_secret(value: str | None) -> str:
|
|
|
476
465
|
|
|
477
466
|
|
|
478
467
|
def _match_context_window(model: str) -> int | None:
|
|
479
|
-
"""Match model name against CONTEXT_WINDOWS
|
|
468
|
+
"""Match model name against CONTEXT_WINDOWS entries (substring match).
|
|
480
469
|
|
|
481
470
|
Args:
|
|
482
471
|
model: The model ID to match (e.g., "gpt-5-mini", "claude-3-opus")
|
|
@@ -486,7 +475,7 @@ def _match_context_window(model: str) -> int | None:
|
|
|
486
475
|
"""
|
|
487
476
|
lowered = model.lower()
|
|
488
477
|
for pattern, size in CONTEXT_WINDOWS:
|
|
489
|
-
if
|
|
478
|
+
if pattern.lower() in lowered:
|
|
490
479
|
return size
|
|
491
480
|
return None
|
|
492
481
|
|
|
@@ -497,7 +486,7 @@ def load_context_windows_config(
|
|
|
497
486
|
"""Load per-model context-window overrides from the config file.
|
|
498
487
|
|
|
499
488
|
Reads the ``context_windows`` object: a mapping of model names or
|
|
500
|
-
|
|
489
|
+
substrings (matched in file order, first match wins) to token
|
|
501
490
|
counts. Keys starting with ``_`` are comments and skipped. A
|
|
502
491
|
missing file, missing section, or unreadable JSON yields ``[]``
|
|
503
492
|
(callers fall back to the built-in table); a malformed section or
|
|
@@ -530,7 +519,7 @@ def get_context_window_for_model(
|
|
|
530
519
|
the built-in table, then the default.
|
|
531
520
|
|
|
532
521
|
The config file's ``context_windows`` object (user overrides) is
|
|
533
|
-
consulted first (
|
|
522
|
+
consulted first (substring match over its keys, first match wins,
|
|
534
523
|
case-insensitive); then the CONTEXT_WINDOWS table in config.py;
|
|
535
524
|
then DEFAULT_CONTEXT_WINDOW.
|
|
536
525
|
|
|
@@ -544,7 +533,7 @@ def get_context_window_for_model(
|
|
|
544
533
|
"""
|
|
545
534
|
lowered = model.lower()
|
|
546
535
|
for pattern, size in load_context_windows_config(config_path):
|
|
547
|
-
if
|
|
536
|
+
if pattern.lower() in lowered:
|
|
548
537
|
return size
|
|
549
538
|
matched = _match_context_window(model)
|
|
550
539
|
if matched is not None:
|
|
@@ -21,9 +21,9 @@ class ContextManager:
|
|
|
21
21
|
from the loop's delegate so the call site keeps resolving them
|
|
22
22
|
through the ``agent`` module namespace (tests patch
|
|
23
23
|
``python_agent_harness.agent.estimate_payload_tokens``). The
|
|
24
|
-
context window comes from the session's client (
|
|
25
|
-
|
|
26
|
-
clients without the property.
|
|
24
|
+
context window comes from the session's client (config-file aware,
|
|
25
|
+
resolved per access); ``context_window_for`` is only the fallback
|
|
26
|
+
for clients without the property.
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
29
|
def __init__(self, loop: Any) -> None:
|
|
@@ -42,9 +42,9 @@ class ContextManager:
|
|
|
42
42
|
)
|
|
43
43
|
loop.session.calibrator.last_raw_estimate = raw
|
|
44
44
|
calibrated = loop.session.calibrator.calibrate(raw)
|
|
45
|
-
# Prefer the client's
|
|
46
|
-
#
|
|
47
|
-
# clients without the property (test doubles).
|
|
45
|
+
# Prefer the client's window (config overrides -> patterns ->
|
|
46
|
+
# default, resolved per access); fall back to the static
|
|
47
|
+
# resolver for clients without the property (test doubles).
|
|
48
48
|
client = loop.session.client
|
|
49
49
|
window = getattr(client, "context_window", None)
|
|
50
50
|
if window is None:
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/manager.py
RENAMED
|
@@ -23,6 +23,7 @@ SDK/connection errors that the tool adapter turns into error strings.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
|
+
import concurrent.futures
|
|
26
27
|
import contextlib
|
|
27
28
|
import threading
|
|
28
29
|
import time
|
|
@@ -102,7 +103,10 @@ class _LoopThread:
|
|
|
102
103
|
while True:
|
|
103
104
|
try:
|
|
104
105
|
exc = future.exception(timeout=0.1)
|
|
105
|
-
|
|
106
|
+
# concurrent.futures.TimeoutError is the builtin TimeoutError on
|
|
107
|
+
# 3.11+ but a distinct class on 3.10 — catch the futures one so
|
|
108
|
+
# the poll-timeout branch works on every supported Python.
|
|
109
|
+
except concurrent.futures.TimeoutError:
|
|
106
110
|
# future still running: enforce deadline / cancellation
|
|
107
111
|
if deadline is not None and time.monotonic() >= deadline:
|
|
108
112
|
future.cancel()
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/planmode.py
RENAMED
|
@@ -64,8 +64,11 @@ class PlanMode:
|
|
|
64
64
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
65
65
|
if not os.path.exists(path):
|
|
66
66
|
Path(path).write_text("", encoding="utf-8")
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
# Store the canonical path so the plan-mode write guard (which
|
|
68
|
+
# compares against realpath'd tool targets) matches — on macOS
|
|
69
|
+
# TMPDIR (/var) is a symlink to /private/var.
|
|
70
|
+
self.plan_file = os.path.realpath(path)
|
|
71
|
+
return self.plan_file
|
|
69
72
|
|
|
70
73
|
def cleanup_plan_file(self) -> None:
|
|
71
74
|
if not self.plan_file:
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/session.py
RENAMED
|
@@ -687,6 +687,9 @@ class Session:
|
|
|
687
687
|
self.client.model = merged["model"]
|
|
688
688
|
self.model = merged["model"]
|
|
689
689
|
self.store.model = merged["model"]
|
|
690
|
+
# the calibration factor is tokenizer-specific: a factor tuned
|
|
691
|
+
# to the previous model must not skew estimates for the new one
|
|
692
|
+
self.calibrator.reset()
|
|
690
693
|
self.backend = merged["backend"]
|
|
691
694
|
self.store.backend = merged["backend"]
|
|
692
695
|
self.temperature = merged["temperature"]
|
|
@@ -44,9 +44,9 @@ def context_window_for(model: str, config_path: str | os.PathLike | None = None)
|
|
|
44
44
|
"""Return the context window for MODEL, or a safe fallback.
|
|
45
45
|
|
|
46
46
|
Delegates to ``config.get_context_window_for_model``: config-file
|
|
47
|
-
``context_windows`` overrides (config.json) first, then
|
|
48
|
-
over CONTEXT_WINDOWS (first match wins, case-insensitive),
|
|
49
|
-
DEFAULT_CONTEXT_WINDOW.
|
|
47
|
+
``context_windows`` overrides (config.json) first, then substring
|
|
48
|
+
match over CONTEXT_WINDOWS (first match wins, case-insensitive),
|
|
49
|
+
then DEFAULT_CONTEXT_WINDOW.
|
|
50
50
|
|
|
51
51
|
Args:
|
|
52
52
|
model: The model ID to look up.
|
|
@@ -68,6 +68,12 @@ class TokenCalibrator:
|
|
|
68
68
|
self.factor = 1.0
|
|
69
69
|
self.last_raw_estimate: int | None = None
|
|
70
70
|
|
|
71
|
+
def reset(self) -> None:
|
|
72
|
+
"""Drop the calibration factor (model switch: the old factor
|
|
73
|
+
was tuned to the previous model's tokenizer)."""
|
|
74
|
+
self.factor = 1.0
|
|
75
|
+
self.last_raw_estimate = None
|
|
76
|
+
|
|
71
77
|
def update(self, actual_input: int | None) -> None:
|
|
72
78
|
raw = self.last_raw_estimate
|
|
73
79
|
if actual_input is None or actual_input <= 0 or raw is None or raw <= 0:
|
{python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/__init__.py
RENAMED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
6
|
+
|
|
5
7
|
from .agent_tool import AgentTool
|
|
6
8
|
from .base import PendingToolResult, Registry, Tool, ToolContext
|
|
7
9
|
from .bash import Bash
|
|
8
|
-
from .
|
|
10
|
+
from .edit import Edit
|
|
11
|
+
from .edit_mac import EditMac
|
|
12
|
+
from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
|
|
9
13
|
from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
|
|
10
14
|
from .planexit import PlanExit
|
|
11
15
|
from .question import Question
|
|
@@ -20,6 +24,7 @@ __all__ = [
|
|
|
20
24
|
"AgentTool",
|
|
21
25
|
"Bash",
|
|
22
26
|
"Edit",
|
|
27
|
+
"EditMac",
|
|
23
28
|
"GlobTool",
|
|
24
29
|
"Grep",
|
|
25
30
|
"Insert",
|
|
@@ -38,6 +43,10 @@ __all__ = [
|
|
|
38
43
|
|
|
39
44
|
def default_registry() -> Registry:
|
|
40
45
|
reg = Registry()
|
|
46
|
+
# macOS's BSD patch rejects well-formed hunks that GNU patch accepts,
|
|
47
|
+
# so macOS uses EditMac (pure-Python diff applier); Linux keeps the
|
|
48
|
+
# patch-binary Edit. Both register under the name "Edit".
|
|
49
|
+
edit_tool = EditMac() if sys.platform == "darwin" else Edit()
|
|
41
50
|
for tool in (
|
|
42
51
|
AgentTool(),
|
|
43
52
|
TodoWrite(),
|
|
@@ -45,7 +54,7 @@ def default_registry() -> Registry:
|
|
|
45
54
|
Grep(),
|
|
46
55
|
Read(),
|
|
47
56
|
Insert(),
|
|
48
|
-
|
|
57
|
+
edit_tool,
|
|
49
58
|
Write(),
|
|
50
59
|
Mkdir(),
|
|
51
60
|
Bash(),
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Pure-Python unified diff applier (macOS Edit backend).
|
|
2
|
+
|
|
3
|
+
macOS ships Apple's old BSD ``/usr/bin/patch`` (patch 2.0-12u11-Apple),
|
|
4
|
+
which rejects well-formed hunks that GNU patch accepts: asymmetric
|
|
5
|
+
context (leading but no trailing context, or vice versa), hunks whose
|
|
6
|
+
last body line is a ``---``-rendered content line, etc. Rather than
|
|
7
|
+
rewriting every diff into a shape that specific patch binary likes, the
|
|
8
|
+
macOS Edit tool applies diffs here, in Python:
|
|
9
|
+
|
|
10
|
+
- the diff is parsed STRUCTURALLY, so hunk-header counts are advisory
|
|
11
|
+
(they are recounted from the body, like ``_fix_patch_headers``) and
|
|
12
|
+
``---``/``+++`` content lines are only file headers when followed by
|
|
13
|
+
their ``+++``/``---`` partner;
|
|
14
|
+
- hunks are matched against the file by content with fuzz (context
|
|
15
|
+
lines may mismatch up to ``FUZZ`` times, removed lines must match
|
|
16
|
+
exactly), preferring the position the header claims;
|
|
17
|
+
- hunks are applied bottom-up so earlier positions stay valid;
|
|
18
|
+
- files are written only when every hunk of the section matched.
|
|
19
|
+
|
|
20
|
+
The applier is platform-independent, so its behavior is covered by
|
|
21
|
+
tests on every OS.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
30
|
+
_FILE_OLD_RE = re.compile(r"^---[ \t]")
|
|
31
|
+
_FILE_NEW_RE = re.compile(r"^\+\+\+[ \t]")
|
|
32
|
+
_HUNK_BOUNDARY_RE = re.compile(r"^@@ ")
|
|
33
|
+
|
|
34
|
+
# Maximum number of context lines that may mismatch when anchoring a
|
|
35
|
+
# hunk (removed lines must always match exactly).
|
|
36
|
+
FUZZ = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Hunk:
|
|
40
|
+
__slots__ = ("old_start", "body")
|
|
41
|
+
|
|
42
|
+
def __init__(self, old_start: int, body: list[tuple[str, str, bool]]) -> None:
|
|
43
|
+
# body entries: (kind, content, no_newline); kind in ' ', '-', '+'.
|
|
44
|
+
self.old_start = old_start
|
|
45
|
+
self.body = body
|
|
46
|
+
|
|
47
|
+
def old_seq(self) -> list[tuple[str, str]]:
|
|
48
|
+
"""(kind, content-without-newline) for the lines that must exist
|
|
49
|
+
in the old file (' ' context and '-' removed lines)."""
|
|
50
|
+
return [
|
|
51
|
+
(kind, content.rstrip("\n")) for kind, content, _ in self.body if kind in (" ", "-")
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Section:
|
|
56
|
+
__slots__ = ("new_path", "hunks")
|
|
57
|
+
|
|
58
|
+
def __init__(self, new_path: str, hunks: list[_Hunk]) -> None:
|
|
59
|
+
self.new_path = new_path
|
|
60
|
+
self.hunks = hunks
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _section_header_at(lines: list[str], idx: int) -> bool:
|
|
64
|
+
"""Whether lines[idx] begins a file section: a ``--- path`` line
|
|
65
|
+
immediately followed by a ``+++ path`` line. Removed/added content
|
|
66
|
+
lines rendered ``---``/``+++`` inside a hunk body are only mistaken
|
|
67
|
+
for headers when the pair shape matches, so the trailing ``+++``
|
|
68
|
+
partner is required (mirrors ``_starts_file_section``)."""
|
|
69
|
+
if idx + 1 >= len(lines):
|
|
70
|
+
return False
|
|
71
|
+
return bool(_FILE_OLD_RE.match(lines[idx]) and _FILE_NEW_RE.match(lines[idx + 1]))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse(diff_text: str) -> list[_Section]:
|
|
75
|
+
"""Parse a unified diff into file sections (paths + hunks)."""
|
|
76
|
+
lines = diff_text.splitlines(keepends=True)
|
|
77
|
+
sections: list[_Section] = []
|
|
78
|
+
i = 0
|
|
79
|
+
n = len(lines)
|
|
80
|
+
while i < n:
|
|
81
|
+
if not _section_header_at(lines, i):
|
|
82
|
+
i += 1
|
|
83
|
+
continue
|
|
84
|
+
new_path = lines[i + 1][len("+++") :].strip()
|
|
85
|
+
i += 2
|
|
86
|
+
hunks: list[_Hunk] = []
|
|
87
|
+
while i < n:
|
|
88
|
+
m = _HUNK_HEADER_RE.match(lines[i])
|
|
89
|
+
if m:
|
|
90
|
+
old_start = int(m.group(1))
|
|
91
|
+
body: list[tuple[str, str, bool]] = []
|
|
92
|
+
i += 1
|
|
93
|
+
while (
|
|
94
|
+
i < n
|
|
95
|
+
and not _HUNK_BOUNDARY_RE.match(lines[i])
|
|
96
|
+
and not _section_header_at(lines, i)
|
|
97
|
+
):
|
|
98
|
+
line = lines[i]
|
|
99
|
+
if line.startswith("\\"): # ""
|
|
100
|
+
if body and body[-1][0] in (" ", "-", "+"):
|
|
101
|
+
kind, content, _ = body[-1]
|
|
102
|
+
body[-1] = (kind, content, True)
|
|
103
|
+
elif line[:1] in (" ", "-", "+"):
|
|
104
|
+
body.append((line[0], line[1:], False))
|
|
105
|
+
i += 1
|
|
106
|
+
hunks.append(_Hunk(old_start, body))
|
|
107
|
+
continue
|
|
108
|
+
if _section_header_at(lines, i):
|
|
109
|
+
break
|
|
110
|
+
i += 1 # stray line (e.g. "diff --git", "index ...") — skip
|
|
111
|
+
sections.append(_Section(new_path, hunks))
|
|
112
|
+
return sections
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _candidate_positions(start: int, limit: int) -> list[int]:
|
|
116
|
+
"""Match offsets around *start* (0-based), closest first, within
|
|
117
|
+
``FUZZ`` lines and bounded by [0, *limit*]."""
|
|
118
|
+
out: list[int] = []
|
|
119
|
+
seen: set[int] = set()
|
|
120
|
+
for d in range(FUZZ + 1):
|
|
121
|
+
for p in (start - d, start + d):
|
|
122
|
+
if 0 <= p <= limit and p not in seen:
|
|
123
|
+
seen.add(p)
|
|
124
|
+
out.append(p)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _match_hunk(hunk: _Hunk, file_lines: list[str]) -> int | None:
|
|
129
|
+
"""Find the 0-based position where the hunk's old lines match.
|
|
130
|
+
|
|
131
|
+
Removed lines must match exactly; up to ``FUZZ`` context lines may
|
|
132
|
+
mismatch (patch-style fuzz). Pure-insertion hunks (no old lines)
|
|
133
|
+
anchor at ``old_start - 1``.
|
|
134
|
+
"""
|
|
135
|
+
old_seq = hunk.old_seq()
|
|
136
|
+
if not old_seq:
|
|
137
|
+
p = hunk.old_start - 1
|
|
138
|
+
return max(0, min(p, len(file_lines)))
|
|
139
|
+
start = hunk.old_start - 1
|
|
140
|
+
limit = len(file_lines) - len(old_seq)
|
|
141
|
+
for p in _candidate_positions(start, limit):
|
|
142
|
+
mismatches = 0
|
|
143
|
+
ok = True
|
|
144
|
+
for i, (kind, content) in enumerate(old_seq):
|
|
145
|
+
file_content = file_lines[p + i].rstrip("\n")
|
|
146
|
+
if kind == "-":
|
|
147
|
+
if file_content != content:
|
|
148
|
+
ok = False
|
|
149
|
+
break
|
|
150
|
+
elif file_content != content:
|
|
151
|
+
mismatches += 1
|
|
152
|
+
if mismatches > FUZZ:
|
|
153
|
+
ok = False
|
|
154
|
+
break
|
|
155
|
+
if ok:
|
|
156
|
+
return p
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _apply_hunk(hunk: _Hunk, pos: int, file_lines: list[str]) -> None:
|
|
161
|
+
"""Replace the matched region with the hunk's new lines in place."""
|
|
162
|
+
old_count = sum(1 for kind, _, _ in hunk.body if kind in (" ", "-"))
|
|
163
|
+
new_block: list[str] = []
|
|
164
|
+
p = pos
|
|
165
|
+
for kind, content, no_newline in hunk.body:
|
|
166
|
+
if kind == " ":
|
|
167
|
+
new_block.append(file_lines[p])
|
|
168
|
+
p += 1
|
|
169
|
+
elif kind == "-":
|
|
170
|
+
p += 1
|
|
171
|
+
else: # "+"
|
|
172
|
+
if no_newline:
|
|
173
|
+
new_block.append(content.rstrip("\n"))
|
|
174
|
+
elif content.endswith("\n"):
|
|
175
|
+
new_block.append(content)
|
|
176
|
+
else:
|
|
177
|
+
new_block.append(content + "\n")
|
|
178
|
+
file_lines[pos : pos + old_count] = new_block
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_target(new_path: str, cwd: str, fallback_path: str | None) -> str:
|
|
182
|
+
"""Resolve a diff's ``+++ path`` against *cwd*, with the single-file
|
|
183
|
+
*fallback_path* as backup when the name does not match an existing
|
|
184
|
+
file (e.g. the model wrote a bare basename for a deep path)."""
|
|
185
|
+
name = new_path
|
|
186
|
+
for prefix in ("a/", "b/"):
|
|
187
|
+
if name.startswith(prefix):
|
|
188
|
+
name = name[len(prefix) :]
|
|
189
|
+
break
|
|
190
|
+
if os.path.isabs(name):
|
|
191
|
+
return name
|
|
192
|
+
target = os.path.join(cwd, name)
|
|
193
|
+
if not os.path.isfile(target) and fallback_path and os.path.isfile(fallback_path):
|
|
194
|
+
return fallback_path
|
|
195
|
+
return target
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_section(section: _Section, cwd: str, fallback_path: str | None) -> tuple[bool, str]:
|
|
199
|
+
target = _resolve_target(section.new_path, cwd, fallback_path)
|
|
200
|
+
if not os.path.isfile(target):
|
|
201
|
+
return False, f"target file does not exist: {target}"
|
|
202
|
+
try:
|
|
203
|
+
with open(target, encoding="utf-8", errors="replace") as f:
|
|
204
|
+
file_lines = f.readlines()
|
|
205
|
+
except OSError as e:
|
|
206
|
+
return False, f"cannot read {target}: {e}"
|
|
207
|
+
# Match every hunk against the ORIGINAL content first; only write
|
|
208
|
+
# when all matched (a failed hunk leaves the file untouched).
|
|
209
|
+
plan: list[tuple[_Hunk, int]] = []
|
|
210
|
+
for hunk in section.hunks:
|
|
211
|
+
pos = _match_hunk(hunk, file_lines)
|
|
212
|
+
if pos is None:
|
|
213
|
+
return (
|
|
214
|
+
False,
|
|
215
|
+
f"hunk at line {hunk.old_start} failed to match the file content",
|
|
216
|
+
)
|
|
217
|
+
plan.append((hunk, pos))
|
|
218
|
+
new_lines = list(file_lines)
|
|
219
|
+
for hunk, pos in reversed(plan): # bottom-up: earlier positions stay valid
|
|
220
|
+
_apply_hunk(hunk, pos, new_lines)
|
|
221
|
+
try:
|
|
222
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
223
|
+
f.writelines(new_lines)
|
|
224
|
+
except OSError as e:
|
|
225
|
+
return False, f"cannot write {target}: {e}"
|
|
226
|
+
return True, f"patched {target}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def apply_unified_diff(
|
|
230
|
+
diff_text: str, cwd: str, fallback_path: str | None = None
|
|
231
|
+
) -> tuple[bool, str]:
|
|
232
|
+
"""Apply a unified diff to files under *cwd*.
|
|
233
|
+
|
|
234
|
+
Returns ``(ok, message)``; on failure no file is written. Multi-file
|
|
235
|
+
diffs are applied section by section. *fallback_path* is used when a
|
|
236
|
+
section's target cannot be resolved to an existing file (single-file
|
|
237
|
+
mode).
|
|
238
|
+
"""
|
|
239
|
+
sections = _parse(diff_text)
|
|
240
|
+
if not sections:
|
|
241
|
+
return False, "no file sections found in diff"
|
|
242
|
+
for section in sections:
|
|
243
|
+
ok, msg = _apply_section(section, cwd, fallback_path)
|
|
244
|
+
if not ok:
|
|
245
|
+
return False, msg
|
|
246
|
+
return True, f"applied {len(sections)} file section(s)"
|