python-agent-harness 1.5.4.2__tar.gz → 1.5.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {python_agent_harness-1.5.4.2/python_agent_harness.egg-info → python_agent_harness-1.5.4.5}/PKG-INFO +4 -4
  2. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/README.md +3 -3
  3. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/agent.py +1 -1
  6. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/cli.py +3 -4
  7. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/config.py +6 -9
  8. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/mcp/manager.py +5 -1
  9. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/persistence.py +0 -3
  10. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/planmode.py +5 -2
  11. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/session.py +1 -8
  12. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/__init__.py +20 -4
  13. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/agent_tool.py +1 -1
  14. python_agent_harness-1.5.4.5/python_agent_harness/tools/diffapply.py +246 -0
  15. python_agent_harness-1.5.4.5/python_agent_harness/tools/edit_mac.py +56 -0
  16. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/filesystem.py +4 -0
  17. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/glob.py +4 -1
  18. python_agent_harness-1.5.4.5/python_agent_harness/tools/glob_mac.py +97 -0
  19. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/grep.py +1 -1
  20. python_agent_harness-1.5.4.5/python_agent_harness/tools/grep_mac.py +92 -0
  21. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5/python_agent_harness.egg-info}/PKG-INFO +4 -4
  22. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness.egg-info/SOURCES.txt +4 -0
  23. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_agents_md.py +5 -5
  24. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_cli.py +1 -3
  25. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_config.py +16 -6
  26. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_filesystem.py +436 -13
  27. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_invariants.py +26 -0
  28. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_persistence.py +12 -15
  29. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_prompts.py +2 -2
  30. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_scenarios.py +0 -1
  31. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_session.py +16 -10
  32. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_subagent.py +0 -1
  33. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_subagent_isolation.py +1 -1
  34. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/LICENSE +0 -0
  35. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/__main__.py +0 -0
  36. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/client.py +0 -0
  37. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/commands.py +0 -0
  38. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/context_manager.py +0 -0
  39. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/diffrender.py +0 -0
  40. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/mcp/__init__.py +0 -0
  41. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/mcp/client.py +0 -0
  42. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/mcp/config.py +0 -0
  43. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/models.py +0 -0
  44. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/agent.md +0 -0
  45. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/build-switch.md +0 -0
  46. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/commands/explain.md +0 -0
  47. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/compact.md +0 -0
  48. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/initialize.md +0 -0
  49. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/plan-mode.md +0 -0
  50. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/plan.md +0 -0
  51. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/review.md +0 -0
  52. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/subagent.md +0 -0
  53. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/summary.md +0 -0
  54. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  55. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts/title.md +0 -0
  56. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/prompts.py +0 -0
  57. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/subagent.py +0 -0
  58. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/token_estimator.py +0 -0
  59. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tool_runner.py +0 -0
  60. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/base.py +0 -0
  61. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/bash.py +0 -0
  62. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/edit.py +0 -0
  63. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/insert.py +0 -0
  64. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/mcp.py +0 -0
  65. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/mkdir.py +0 -0
  66. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/planexit.py +0 -0
  67. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/question.py +0 -0
  68. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/read.py +0 -0
  69. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/skill.py +0 -0
  70. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/todo.py +0 -0
  71. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tools/write.py +0 -0
  72. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tui/__init__.py +0 -0
  73. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tui/commands.py +0 -0
  74. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tui/core.py +0 -0
  75. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tui/input.py +0 -0
  76. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness/tui/render.py +0 -0
  77. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  78. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness.egg-info/entry_points.txt +0 -0
  79. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness.egg-info/requires.txt +0 -0
  80. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/python_agent_harness.egg-info/top_level.txt +0 -0
  81. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/setup.cfg +0 -0
  82. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_client.py +0 -0
  83. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_commands.py +0 -0
  84. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_concurrent_subagents.py +0 -0
  85. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_context_rules.py +0 -0
  86. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_diffrender.py +0 -0
  87. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_mcp.py +0 -0
  88. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_models.py +0 -0
  89. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_planmode.py +0 -0
  90. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_todos_scope.py +0 -0
  91. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_token_estimator.py +0 -0
  92. {python_agent_harness-1.5.4.2 → python_agent_harness-1.5.4.5}/tests/test_tools_misc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4.2
3
+ Version: 1.5.4.5
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
6
  License-Expression: MIT
@@ -186,13 +186,13 @@ All LLM settings live in a single JSON configuration file. Environment variables
186
186
 
187
187
  ### Configuration options
188
188
 
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`.
189
+ - **`llm`** — main LLM configuration. Optional keys include `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
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`.
195
- - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`, `_BACKEND`).
195
+ - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
196
196
  - **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
197
197
  - **LLM logging** — request and response bodies are logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change the directory. The log path is printed at startup.
198
198
 
@@ -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
@@ -163,13 +163,13 @@ All LLM settings live in a single JSON configuration file. Environment variables
163
163
 
164
164
  ### Configuration options
165
165
 
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`.
166
+ - **`llm`** — main LLM configuration. Optional keys include `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
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`.
172
- - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`, `_BACKEND`).
172
+ - **Configuration precedence** — code defaults < config file < `OPENAI_*` environment variables. Sub-agent settings also support `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`).
173
173
  - **Custom config** — use `--config PATH` or `PYTHON_AGENT_HARNESS_CONFIG`.
174
174
  - **LLM logging** — request and response bodies are logged as JSON to `/tmp/python-agent-harness-<date>-<id>.json`. Set `LLM_LOG_DIR` to change the directory. The log path is printed at startup.
175
175
 
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.4.2"
7
+ version = "1.5.4.5"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ from .mcp.manager import MCPManager
5
5
  from .models import AgentMode, Message, ToolCall, ToolSpec
6
6
  from .session import Session
7
7
 
8
- __version__ = "1.5.4.2"
8
+ __version__ = "1.5.4.5"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -452,7 +452,7 @@ class AgentLoop:
452
452
  )
453
453
  # sub-agent runs use their own LLM when one is configured
454
454
  # (a per-invocation clone of session.subagent_client,
455
- # mirroring gptel-agent-harness-subagent-model/-backend);
455
+ # mirroring gptel-agent-harness-subagent-model);
456
456
  # everything unset inherits the main agent's settings, so
457
457
  # the sub-agent path is identical when no separate LLM is
458
458
  # configured
@@ -54,8 +54,8 @@ def make_session(
54
54
  config_path=config_path,
55
55
  )
56
56
  # A separate client for sub-agent requests only when a different
57
- # LLM is configured (mirrors gptel-agent-harness-subagent-model /
58
- # -backend); otherwise the sub-agent shares the main client.
57
+ # LLM is configured (mirrors gptel-agent-harness-subagent-model);
58
+ # otherwise the sub-agent shares the main client.
59
59
  subagent_client = None
60
60
  if any(
61
61
  subagent_settings[k] != settings[k] for k in ("base_url", "api_key", "model", "timeout")
@@ -101,7 +101,6 @@ def make_session(
101
101
  project_dir=abs_project,
102
102
  client=client,
103
103
  model=model,
104
- backend=settings["backend"],
105
104
  system_prompt=system_prompt,
106
105
  subagent_system_prompt=subagent_system_prompt,
107
106
  temperature=settings["temperature"],
@@ -176,7 +175,7 @@ def cmd_config(args: argparse.Namespace) -> int:
176
175
  print(f"config file: {path}")
177
176
  if not path.exists():
178
177
  print("(file does not exist yet — run `python-agent-harness config --init` to create it)")
179
- for key in ("base_url", "model", "backend"):
178
+ for key in ("base_url", "model"):
180
179
  print(f"{key}: {settings[key]}")
181
180
  print(f"api_key: {config.mask_secret(settings['api_key'])}")
182
181
  print(f"temperature: {settings['temperature']}")
@@ -168,7 +168,6 @@ DEFAULT_LLM: dict = {
168
168
  "base_url": "https://api.openai.com/v1",
169
169
  "api_key": None,
170
170
  "model": "gpt-5-mini",
171
- "backend": "OpenAI-compatible",
172
171
  "temperature": TEMPERATURE,
173
172
  "max_tokens": MAX_TOKENS,
174
173
  "timeout": 600.0,
@@ -182,8 +181,7 @@ DEFAULT_PATHS: dict = {
182
181
  }
183
182
 
184
183
  # Sub-agent LLM overrides: every key defaults to None, meaning "inherit
185
- # the main LLM setting" (mirrors gptel-agent-harness-subagent-model /
186
- # -backend). Only the keys the user actually sets differ from the main
184
+ # the main LLM setting" (mirrors gptel-agent-harness-subagent-model).
187
185
  # agent's LLM. ``profile`` references a named profile from the
188
186
  # ``models`` section: its settings are applied on top of any explicit
189
187
  # subagent_llm keys (profile wins), and unset keys still inherit the
@@ -193,7 +191,6 @@ DEFAULT_SUBAGENT_LLM: dict = {
193
191
  "base_url": None,
194
192
  "api_key": None,
195
193
  "model": None,
196
- "backend": None,
197
194
  "temperature": None,
198
195
  "max_tokens": None,
199
196
  "timeout": None,
@@ -264,14 +261,12 @@ _ENV_OVERRIDES = {
264
261
  "base_url": "OPENAI_BASE_URL",
265
262
  "api_key": "OPENAI_API_KEY",
266
263
  "model": "OPENAI_MODEL",
267
- "backend": "OPENAI_BACKEND",
268
264
  }
269
265
 
270
266
  _SUBAGENT_ENV_OVERRIDES = {
271
267
  "base_url": "OPENAI_SUBAGENT_BASE_URL",
272
268
  "api_key": "OPENAI_SUBAGENT_API_KEY",
273
269
  "model": "OPENAI_SUBAGENT_MODEL",
274
- "backend": "OPENAI_SUBAGENT_BACKEND",
275
270
  }
276
271
 
277
272
 
@@ -320,7 +315,6 @@ def load_llm_config(path: str | os.PathLike | None = None) -> dict:
320
315
  "base_url",
321
316
  "api_key",
322
317
  "model",
323
- "backend",
324
318
  "temperature",
325
319
  "max_tokens",
326
320
  "timeout",
@@ -342,7 +336,7 @@ def load_subagent_llm_config(
342
336
  ) -> dict:
343
337
  """Resolve sub-agent LLM settings; unset keys inherit ``main``.
344
338
 
345
- Mirrors gptel-agent-harness-subagent-model/-backend: sub-agents
339
+ Mirrors gptel-agent-harness-subagent-model: sub-agents
346
340
  (the Agent tool) use their own LLM when configured, otherwise the
347
341
  main agent's. Precedence: ``main`` settings < config file
348
342
  ``subagent_llm`` object < referenced ``models`` profile (when
@@ -379,7 +373,6 @@ def load_subagent_llm_config(
379
373
  "base_url",
380
374
  "api_key",
381
375
  "model",
382
- "backend",
383
376
  "temperature",
384
377
  "max_tokens",
385
378
  "timeout",
@@ -507,6 +500,10 @@ def load_context_windows_config(
507
500
  raise ValueError(
508
501
  f"config file {_config_path(path)}: context_windows.{pattern} must be an integer"
509
502
  )
503
+ if size <= 0:
504
+ raise ValueError(
505
+ f"config file {_config_path(path)}: context_windows.{pattern} must be positive"
506
+ )
510
507
  entries.append((pattern, size))
511
508
  return entries
512
509
 
@@ -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
- except TimeoutError:
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()
@@ -107,7 +107,6 @@ class SessionPersistence:
107
107
  self,
108
108
  project_dir: str,
109
109
  model: str,
110
- backend: str,
111
110
  system_prompt: str | None = None,
112
111
  temperature: float | None = None,
113
112
  max_tokens: int | None = None,
@@ -116,7 +115,6 @@ class SessionPersistence:
116
115
  ) -> None:
117
116
  self.project_dir = project_dir
118
117
  self.model = model
119
- self.backend = backend
120
118
  self.system_prompt = system_prompt
121
119
  self.temperature = temperature
122
120
  self.max_tokens = max_tokens
@@ -173,7 +171,6 @@ class SessionPersistence:
173
171
  pairs = [
174
172
  ("python-agent-harness--project-dir", self.project_dir),
175
173
  ("gptel-model", self.model),
176
- ("gptel--backend-name", self.backend),
177
174
  ("gptel-system-prompt", self.system_prompt),
178
175
  ("gptel-temperature", self.temperature),
179
176
  ("gptel-max-tokens", self.max_tokens),
@@ -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
- self.plan_file = path
68
- return path
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:
@@ -71,7 +71,6 @@ class Session:
71
71
  project_dir: str,
72
72
  client: Client,
73
73
  model: str,
74
- backend: str = "OpenAI-compatible",
75
74
  system_prompt: str | None = None,
76
75
  subagent_system_prompt: str | None = None,
77
76
  temperature: float = config.TEMPERATURE,
@@ -96,7 +95,6 @@ class Session:
96
95
  self.project_dir = project_dir
97
96
  self.client = client
98
97
  self.model = model
99
- self.backend = backend
100
98
  self.system_prompt = system_prompt
101
99
  self.subagent_system_prompt = subagent_system_prompt
102
100
  self.temperature = temperature
@@ -113,7 +111,7 @@ class Session:
113
111
  # Sub-agent LLM: a dedicated client (base_url/api_key/model/
114
112
  # timeout) and per-request options when a different LLM is
115
113
  # configured for sub-agents (mirrors gptel-agent-harness-
116
- # subagent-model/-backend); every unset option inherits the
114
+ # subagent-model); every unset option inherits the
117
115
  # main agent's value. The sub-agent loop never uses this
118
116
  # client directly — each Agent tool invocation clones it
119
117
  # (see run_subagent) so concurrent sub-agents never share a
@@ -161,7 +159,6 @@ class Session:
161
159
  self.store = SessionPersistence(
162
160
  project_dir=project_dir,
163
161
  model=model,
164
- backend=backend,
165
162
  system_prompt=system_prompt,
166
163
  temperature=temperature,
167
164
  max_tokens=max_tokens,
@@ -659,7 +656,6 @@ class Session:
659
656
  "base_url": self.client.base_url,
660
657
  "api_key": self.client.api_key,
661
658
  "model": self.model,
662
- "backend": self.backend,
663
659
  "temperature": self.temperature,
664
660
  "max_tokens": self.max_tokens,
665
661
  "timeout": self.client.timeout,
@@ -673,7 +669,6 @@ class Session:
673
669
  "base_url",
674
670
  "api_key",
675
671
  "model",
676
- "backend",
677
672
  "temperature",
678
673
  "max_tokens",
679
674
  "timeout",
@@ -690,8 +685,6 @@ class Session:
690
685
  # the calibration factor is tokenizer-specific: a factor tuned
691
686
  # to the previous model must not skew estimates for the new one
692
687
  self.calibrator.reset()
693
- self.backend = merged["backend"]
694
- self.store.backend = merged["backend"]
695
688
  self.temperature = merged["temperature"]
696
689
  self.max_tokens = merged["max_tokens"]
697
690
  if hasattr(self.client, "set_timeout"):
@@ -2,10 +2,16 @@
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 .filesystem import Edit, GlobTool, Grep, Insert, Mkdir, Read, Write
10
+ from .edit import Edit
11
+ from .edit_mac import EditMac
12
+ from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
13
+ from .glob_mac import GlobMac
14
+ from .grep_mac import GrepMac
9
15
  from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
10
16
  from .planexit import PlanExit
11
17
  from .question import Question
@@ -20,8 +26,11 @@ __all__ = [
20
26
  "AgentTool",
21
27
  "Bash",
22
28
  "Edit",
29
+ "EditMac",
30
+ "GlobMac",
23
31
  "GlobTool",
24
32
  "Grep",
33
+ "GrepMac",
25
34
  "Insert",
26
35
  "MCPTool",
27
36
  "Mkdir",
@@ -38,14 +47,21 @@ __all__ = [
38
47
 
39
48
  def default_registry() -> Registry:
40
49
  reg = Registry()
50
+ # macOS lacks GNU `patch`, `tree`, and PCRE-enabled git — use
51
+ # pure-Python / POSIX-compatible Mac variants that register under
52
+ # the same tool names so callers are platform-independent.
53
+ _mac = sys.platform == "darwin"
54
+ edit_tool = EditMac() if _mac else Edit()
55
+ glob_tool = GlobMac() if _mac else GlobTool()
56
+ grep_tool = GrepMac() if _mac else Grep()
41
57
  for tool in (
42
58
  AgentTool(),
43
59
  TodoWrite(),
44
- GlobTool(),
45
- Grep(),
60
+ glob_tool,
61
+ grep_tool,
46
62
  Read(),
47
63
  Insert(),
48
- Edit(),
64
+ edit_tool,
49
65
  Write(),
50
66
  Mkdir(),
51
67
  Bash(),
@@ -6,7 +6,7 @@ sub-agent loop, delivering the result string when it finishes — a
6
6
  long-running sub-agent never blocks the parent's sequential tool loop.
7
7
 
8
8
  Sub-agents run the same agent loop with a fresh loop instance; their
9
- backend/model can be overridden (see config). Results flow back to the
9
+ model can be overridden (see config). Results flow back to the
10
10
  parent as a single tool result string. Errors are contained: an
11
11
  unexpected sub-agent response becomes an error string fed to the parent,
12
12
  never a crash.
@@ -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)"