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.
Files changed (90) hide show
  1. {python_agent_harness-1.5.4.1/python_agent_harness.egg-info → python_agent_harness-1.5.4.3}/PKG-INFO +5 -5
  2. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/README.md +4 -4
  3. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/client.py +11 -15
  6. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/config.py +22 -33
  7. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/context_manager.py +6 -6
  8. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/manager.py +5 -1
  9. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/planmode.py +5 -2
  10. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/session.py +3 -0
  11. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/token_estimator.py +9 -3
  12. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/__init__.py +11 -2
  13. python_agent_harness-1.5.4.3/python_agent_harness/tools/diffapply.py +246 -0
  14. python_agent_harness-1.5.4.3/python_agent_harness/tools/edit_mac.py +56 -0
  15. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/glob.py +4 -1
  16. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/grep.py +1 -1
  17. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3/python_agent_harness.egg-info}/PKG-INFO +5 -5
  18. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/SOURCES.txt +2 -0
  19. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_agents_md.py +5 -5
  20. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_client.py +47 -17
  21. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_config.py +39 -8
  22. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_filesystem.py +179 -13
  23. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_invariants.py +26 -0
  24. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_prompts.py +2 -2
  25. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_session.py +47 -4
  26. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_token_estimator.py +14 -1
  27. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/LICENSE +0 -0
  28. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/__main__.py +0 -0
  29. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/agent.py +0 -0
  30. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/cli.py +0 -0
  31. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/commands.py +0 -0
  32. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/diffrender.py +0 -0
  33. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/__init__.py +0 -0
  34. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/client.py +0 -0
  35. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/mcp/config.py +0 -0
  36. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/models.py +0 -0
  37. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/persistence.py +0 -0
  38. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/agent.md +0 -0
  39. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/build-switch.md +0 -0
  40. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/commands/explain.md +0 -0
  41. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/compact.md +0 -0
  42. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/initialize.md +0 -0
  43. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan-mode.md +0 -0
  44. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/plan.md +0 -0
  45. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/review.md +0 -0
  46. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/subagent.md +0 -0
  47. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/summary.md +0 -0
  48. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  49. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts/title.md +0 -0
  50. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/prompts.py +0 -0
  51. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/subagent.py +0 -0
  52. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tool_runner.py +0 -0
  53. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/agent_tool.py +0 -0
  54. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/base.py +0 -0
  55. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/bash.py +0 -0
  56. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/edit.py +0 -0
  57. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/filesystem.py +0 -0
  58. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/insert.py +0 -0
  59. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mcp.py +0 -0
  60. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/mkdir.py +0 -0
  61. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/planexit.py +0 -0
  62. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/question.py +0 -0
  63. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/read.py +0 -0
  64. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/skill.py +0 -0
  65. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/todo.py +0 -0
  66. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tools/write.py +0 -0
  67. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/__init__.py +0 -0
  68. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/commands.py +0 -0
  69. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/core.py +0 -0
  70. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/input.py +0 -0
  71. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness/tui/render.py +0 -0
  72. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  73. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
  74. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/requires.txt +0 -0
  75. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/python_agent_harness.egg-info/top_level.txt +0 -0
  76. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/setup.cfg +0 -0
  77. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_cli.py +0 -0
  78. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_commands.py +0 -0
  79. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_concurrent_subagents.py +0 -0
  80. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_context_rules.py +0 -0
  81. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_diffrender.py +0 -0
  82. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_mcp.py +0 -0
  83. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_models.py +0 -0
  84. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_persistence.py +0 -0
  85. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_planmode.py +0 -0
  86. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_scenarios.py +0 -0
  87. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_subagent.py +0 -0
  88. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_subagent_isolation.py +0 -0
  89. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/tests/test_todos_scope.py +0 -0
  90. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.3}/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.1
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 fnmatch patterns (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
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 fnmatch patterns (e.g., `deepseek-v4*`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
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 fnmatch patterns (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
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 fnmatch patterns (e.g., `deepseek-v4*`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
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
@@ -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.1"
7
+ version = "1.5.4.3"
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.1"
8
+ __version__ = "1.5.4.3"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -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. The resolved value is
237
- cached for the life of the client.
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
- if self._context_window is None:
240
- try:
241
- self._context_window = config.get_context_window_for_model(
242
- self.model, config_path=self._config_path
243
- )
244
- except Exception:
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()
@@ -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. Supports wildcards (*). Trailing `*`
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*", 128_000),
25
- ("gpt-5*", 400_000),
26
- ("gpt-oss-120b*", 128_000),
27
- ("claude*", 200_000),
28
- ("deepseek-v3*", 128_000),
29
- ("deepseek-v4*", 1_000_000),
30
- ("qwen3.5*", 131_072),
31
- ("qwen3.6*", 262_144),
32
- ("qwen3.8*", 262_144),
33
- ("qwen3*", 131_072),
34
- ("glm-5.2*", 1_000_000),
35
- ("glm-5.1*", 128_000),
36
- ("kimi-k2.7*", 256_000),
37
- ("kimi*", 128_000),
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 fnmatch patterns (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.",
237
- "deepseek-v4*": 1000000
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 patterns (supports wildcards).
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 fnmatch.fnmatch(lowered, pattern.lower()):
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
- fnmatch patterns (matched in file order, first match wins) to token
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 (fnmatch over its patterns, first match wins,
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 fnmatch.fnmatch(lowered, pattern.lower()):
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 (cached, config-
25
- file aware); ``context_window_for`` is only the fallback for
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 cached window (config overrides ->
46
- # patterns -> default); fall back to the static resolver for
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:
@@ -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()
@@ -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:
@@ -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 fnmatch
48
- over CONTEXT_WINDOWS (first match wins, case-insensitive), then
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:
@@ -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 .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
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
- Edit(),
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)"