python-agent-harness 1.5.4.1__tar.gz → 1.5.4.2__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 (88) hide show
  1. {python_agent_harness-1.5.4.1/python_agent_harness.egg-info → python_agent_harness-1.5.4.2}/PKG-INFO +4 -4
  2. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/README.md +3 -3
  3. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/pyproject.toml +1 -1
  4. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/client.py +11 -15
  6. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/config.py +22 -33
  7. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/context_manager.py +6 -6
  8. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/session.py +3 -0
  9. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/token_estimator.py +9 -3
  10. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2/python_agent_harness.egg-info}/PKG-INFO +4 -4
  11. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_client.py +47 -17
  12. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_config.py +39 -8
  13. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_session.py +31 -0
  14. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_token_estimator.py +14 -1
  15. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/LICENSE +0 -0
  16. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/__main__.py +0 -0
  17. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/agent.py +0 -0
  18. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/cli.py +0 -0
  19. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/commands.py +0 -0
  20. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/diffrender.py +0 -0
  21. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/__init__.py +0 -0
  22. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/client.py +0 -0
  23. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/config.py +0 -0
  24. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/manager.py +0 -0
  25. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/models.py +0 -0
  26. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/persistence.py +0 -0
  27. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/planmode.py +0 -0
  28. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/agent.md +0 -0
  29. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/build-switch.md +0 -0
  30. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/commands/explain.md +0 -0
  31. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/compact.md +0 -0
  32. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/initialize.md +0 -0
  33. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan-mode.md +0 -0
  34. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan.md +0 -0
  35. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/review.md +0 -0
  36. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/subagent.md +0 -0
  37. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/summary.md +0 -0
  38. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  39. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/title.md +0 -0
  40. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts.py +0 -0
  41. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/subagent.py +0 -0
  42. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tool_runner.py +0 -0
  43. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/__init__.py +0 -0
  44. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/agent_tool.py +0 -0
  45. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/base.py +0 -0
  46. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/bash.py +0 -0
  47. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/edit.py +0 -0
  48. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/filesystem.py +0 -0
  49. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/glob.py +0 -0
  50. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/grep.py +0 -0
  51. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/insert.py +0 -0
  52. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mcp.py +0 -0
  53. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mkdir.py +0 -0
  54. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/planexit.py +0 -0
  55. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/question.py +0 -0
  56. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/read.py +0 -0
  57. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/skill.py +0 -0
  58. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/todo.py +0 -0
  59. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/write.py +0 -0
  60. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/__init__.py +0 -0
  61. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/commands.py +0 -0
  62. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/core.py +0 -0
  63. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/input.py +0 -0
  64. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/render.py +0 -0
  65. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  66. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  67. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/entry_points.txt +0 -0
  68. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/requires.txt +0 -0
  69. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/top_level.txt +0 -0
  70. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/setup.cfg +0 -0
  71. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_agents_md.py +0 -0
  72. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_cli.py +0 -0
  73. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_commands.py +0 -0
  74. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_concurrent_subagents.py +0 -0
  75. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_context_rules.py +0 -0
  76. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_diffrender.py +0 -0
  77. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_filesystem.py +0 -0
  78. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_invariants.py +0 -0
  79. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_mcp.py +0 -0
  80. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_models.py +0 -0
  81. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_persistence.py +0 -0
  82. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_planmode.py +0 -0
  83. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_prompts.py +0 -0
  84. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_scenarios.py +0 -0
  85. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_subagent.py +0 -0
  86. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_subagent_isolation.py +0 -0
  87. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/tests/test_todos_scope.py +0 -0
  88. {python_agent_harness-1.5.4.1 → python_agent_harness-1.5.4.2}/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.2
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`.
@@ -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`.
@@ -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.2"
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.2"
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:
@@ -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:
@@ -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.2
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`.
@@ -1694,36 +1694,49 @@ class TestAuthRefreshOn401(unittest.TestCase):
1694
1694
 
1695
1695
  class TestContextWindow(unittest.TestCase):
1696
1696
  """Client.context_window: config-file overrides -> CONTEXT_WINDOWS
1697
- patterns -> DEFAULT_CONTEXT_WINDOW. The resolved value is cached
1698
- for the life of the client."""
1697
+ patterns -> DEFAULT_CONTEXT_WINDOW. Resolved on every access (no
1698
+ caching), so model switches and config edits take effect at once."""
1699
1699
 
1700
1700
  def _client(self, model: str, config_path: str | None = None) -> Client:
1701
1701
  c = Client(base_url="http://x/v1", api_key="k", model=model, config_path=config_path)
1702
1702
  self.addCleanup(c.close)
1703
1703
  return c
1704
1704
 
1705
+ def test_model_change_re_resolves_window(self):
1706
+ """Changing client.model (runtime /model switch) resolves the
1707
+ new model's window on the next access."""
1708
+ with tempfile.TemporaryDirectory() as d:
1709
+ p = Path(d) / "config.json"
1710
+ p.write_text('{"context_windows": {"fake": 999999}}', encoding="utf-8")
1711
+ c = self._client(model="fake", config_path=str(p))
1712
+ self.assertEqual(c.context_window, 999_999)
1713
+ c.model = "gpt-5-mini"
1714
+ self.assertEqual(c.context_window, 128_000)
1715
+ c.model = "deepseek-v4-flash"
1716
+ self.assertEqual(c.context_window, 1_000_000)
1717
+
1705
1718
  def test_config_file_override_wins(self):
1706
1719
  """A context_windows entry in the config file beats the
1707
- built-in table, and the resolved value is cached."""
1720
+ built-in table, and edits are picked up on the next access."""
1708
1721
  with tempfile.TemporaryDirectory() as d:
1709
1722
  p = Path(d) / "config.json"
1710
- p.write_text('{"context_windows": {"fake*": 999999}}', encoding="utf-8")
1723
+ p.write_text('{"context_windows": {"fake": 999999}}', encoding="utf-8")
1711
1724
  c = self._client(model="fake", config_path=str(p))
1712
1725
  self.assertEqual(c.context_window, 999_999)
1713
- # cached: a later config change must not affect the window
1714
- p.write_text('{"context_windows": {"fake*": 111111}}', encoding="utf-8")
1715
- self.assertEqual(c.context_window, 999_999)
1726
+ # no caching: a later config change takes effect immediately
1727
+ p.write_text('{"context_windows": {"fake": 111111}}', encoding="utf-8")
1728
+ self.assertEqual(c.context_window, 111_111)
1716
1729
 
1717
- def test_config_file_wildcard_matching(self):
1718
- """Config-file patterns support fnmatch wildcards, first match
1730
+ def test_config_file_substring_matching(self):
1731
+ """Config-file keys are matched as substrings, first match
1719
1732
  wins in file order."""
1720
1733
  with tempfile.TemporaryDirectory() as d:
1721
1734
  p = Path(d) / "config.json"
1722
1735
  p.write_text(
1723
- '{"context_windows": {"gpt-4*": 500000, "gpt-4-turbo": 300000}}',
1736
+ '{"context_windows": {"gpt-4": 500000, "gpt-4-turbo": 300000}}',
1724
1737
  encoding="utf-8",
1725
1738
  )
1726
- # "gpt-4-turbo" hits the FIRST entry ("gpt-4*")
1739
+ # "gpt-4-turbo" hits the FIRST entry ("gpt-4")
1727
1740
  self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 500_000)
1728
1741
  self.assertEqual(self._client("gpt-4o", str(p)).context_window, 500_000)
1729
1742
 
@@ -1732,19 +1745,19 @@ class TestContextWindow(unittest.TestCase):
1732
1745
  built-in CONTEXT_WINDOWS table."""
1733
1746
  with tempfile.TemporaryDirectory() as d:
1734
1747
  p = Path(d) / "config.json"
1735
- p.write_text('{"context_windows": {"deepseek-v4*": 1000000}}', encoding="utf-8")
1748
+ p.write_text('{"context_windows": {"deepseek-v4": 1000000}}', encoding="utf-8")
1736
1749
  self.assertEqual(self._client("gpt-5-mini", str(p)).context_window, 128_000)
1737
1750
  self.assertEqual(self._client("deepseek-v4-flash", str(p)).context_window, 1_000_000)
1738
1751
 
1739
1752
  def test_fallback_to_pattern_match(self):
1740
- """No config-file overrides -> CONTEXT_WINDOWS wildcard."""
1753
+ """No config-file overrides -> CONTEXT_WINDOWS substring match."""
1741
1754
  with tempfile.TemporaryDirectory() as d:
1742
1755
  p = Path(d) / "config.json"
1743
1756
  p.write_text('{"llm": {"model": "x"}}', encoding="utf-8")
1744
1757
  self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 128_000)
1745
1758
 
1746
1759
  def test_fallback_to_default(self):
1747
- """No config-file overrides and no pattern match ->
1760
+ """No config-file overrides and no table match ->
1748
1761
  DEFAULT_CONTEXT_WINDOW."""
1749
1762
  with tempfile.TemporaryDirectory() as d:
1750
1763
  p = Path(d) / "config.json"
@@ -1755,19 +1768,36 @@ class TestContextWindow(unittest.TestCase):
1755
1768
  )
1756
1769
 
1757
1770
  def test_missing_config_file_uses_table(self):
1758
- """No config file at all -> CONTEXT_WINDOWS patterns."""
1771
+ """No config file at all -> CONTEXT_WINDOWS table."""
1759
1772
  self.assertEqual(
1760
1773
  self._client("deepseek-v4-flash", "/no/such/config.json").context_window,
1761
1774
  1_000_000,
1762
1775
  )
1763
1776
 
1777
+ def test_provider_prefixed_model_name(self):
1778
+ """Provider-prefixed names (e.g. ZhipuAI/GLM-5.2) resolve the
1779
+ right window via case-insensitive substring match."""
1780
+ self.assertEqual(
1781
+ self._client("ZhipuAI/GLM-5.2", "/no/such/config.json").context_window,
1782
+ 1_000_000,
1783
+ )
1784
+ self.assertEqual(
1785
+ self._client("ZhipuAI/GLM-5.1", "/no/such/config.json").context_window,
1786
+ 128_000,
1787
+ )
1788
+
1764
1789
  def test_malformed_config_file_falls_back_to_default(self):
1765
1790
  """A broken context_windows section must not break the loop:
1766
- the client caches DEFAULT_CONTEXT_WINDOW."""
1791
+ the client uses DEFAULT_CONTEXT_WINDOW for that access, and
1792
+ recovers once the file is fixed (failures are not cached)."""
1767
1793
  with tempfile.TemporaryDirectory() as d:
1768
1794
  p = Path(d) / "config.json"
1769
1795
  p.write_text('{"context_windows": {"fake": "not-a-number"}}', encoding="utf-8")
1770
- self.assertEqual(self._client("fake", str(p)).context_window, 128_000)
1796
+ c = self._client("fake", str(p))
1797
+ self.assertEqual(c.context_window, 128_000)
1798
+ # no caching of the failure: a fixed file is picked up
1799
+ p.write_text('{"context_windows": {"fake": 999999}}', encoding="utf-8")
1800
+ self.assertEqual(c.context_window, 999_999)
1771
1801
 
1772
1802
 
1773
1803
  if __name__ == "__main__":
@@ -218,22 +218,22 @@ class TestContextWindowsConfig(unittest.TestCase):
218
218
  with tempfile.TemporaryDirectory() as d:
219
219
  p = Path(d) / "config.json"
220
220
  p.write_text(
221
- '{"context_windows": {"deepseek-v4*": 1000000, "gpt-5*": 400000}}',
221
+ '{"context_windows": {"deepseek-v4": 1000000, "gpt-5": 400000}}',
222
222
  encoding="utf-8",
223
223
  )
224
224
  self.assertEqual(
225
225
  config.load_context_windows_config(p),
226
- [("deepseek-v4*", 1000000), ("gpt-5*", 400000)],
226
+ [("deepseek-v4", 1000000), ("gpt-5", 400000)],
227
227
  )
228
228
 
229
229
  def test_comment_keys_skipped(self):
230
230
  with tempfile.TemporaryDirectory() as d:
231
231
  p = Path(d) / "config.json"
232
232
  p.write_text(
233
- '{"context_windows": {"_comment": "hi", "kimi*": 256000}}',
233
+ '{"context_windows": {"_comment": "hi", "kimi": 256000}}',
234
234
  encoding="utf-8",
235
235
  )
236
- self.assertEqual(config.load_context_windows_config(p), [("kimi*", 256000)])
236
+ self.assertEqual(config.load_context_windows_config(p), [("kimi", 256000)])
237
237
 
238
238
  def test_section_must_be_object(self):
239
239
  with tempfile.TemporaryDirectory() as d:
@@ -245,7 +245,7 @@ class TestContextWindowsConfig(unittest.TestCase):
245
245
  def test_size_must_be_integer(self):
246
246
  with tempfile.TemporaryDirectory() as d:
247
247
  p = Path(d) / "config.json"
248
- p.write_text('{"context_windows": {"m*": "big"}}', encoding="utf-8")
248
+ p.write_text('{"context_windows": {"m": "big"}}', encoding="utf-8")
249
249
  with self.assertRaises(ValueError):
250
250
  config.load_context_windows_config(p)
251
251
 
@@ -253,7 +253,7 @@ class TestContextWindowsConfig(unittest.TestCase):
253
253
  """True is an int subclass but not a valid token count."""
254
254
  with tempfile.TemporaryDirectory() as d:
255
255
  p = Path(d) / "config.json"
256
- p.write_text('{"context_windows": {"m*": true}}', encoding="utf-8")
256
+ p.write_text('{"context_windows": {"m": true}}', encoding="utf-8")
257
257
  with self.assertRaises(ValueError):
258
258
  config.load_context_windows_config(p)
259
259
 
@@ -262,7 +262,7 @@ class TestContextWindowsConfig(unittest.TestCase):
262
262
  with tempfile.TemporaryDirectory() as d:
263
263
  p = Path(d) / "config.json"
264
264
  p.write_text(
265
- '{"context_windows": {"deepseek-v4*": 1000000, "gpt-4-turbo": 300000}}',
265
+ '{"context_windows": {"deepseek-v4": 1000000, "gpt-4-turbo": 300000}}',
266
266
  encoding="utf-8",
267
267
  )
268
268
  self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
@@ -280,9 +280,40 @@ class TestContextWindowsConfig(unittest.TestCase):
280
280
  def test_get_context_window_case_insensitive(self):
281
281
  with tempfile.TemporaryDirectory() as d:
282
282
  p = Path(d) / "config.json"
283
- p.write_text('{"context_windows": {"DeepSeek-V4*": 1000000}}', encoding="utf-8")
283
+ p.write_text('{"context_windows": {"DeepSeek-V4": 1000000}}', encoding="utf-8")
284
284
  self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
285
285
 
286
+ def test_get_context_window_provider_prefixed_name(self):
287
+ """Provider-prefixed model names (e.g. "ZhipuAI/GLM-5.2") match
288
+ their table entry via case-insensitive substring match."""
289
+ # provider prefix + uppercase org, model name in the middle
290
+ self.assertEqual(
291
+ config.get_context_window_for_model("ZhipuAI/GLM-5.2", "/no/such/config.json"),
292
+ 1_000_000,
293
+ )
294
+ # lowercased provider prefix matches too
295
+ self.assertEqual(
296
+ config.get_context_window_for_model("zhipuai/glm-5.2", "/no/such/config.json"),
297
+ 1_000_000,
298
+ )
299
+ # plain model name still matches
300
+ self.assertEqual(
301
+ config.get_context_window_for_model("glm-5.2", "/no/such/config.json"),
302
+ 1_000_000,
303
+ )
304
+ # a different minor version must NOT hit the glm-5.2 entry
305
+ self.assertEqual(
306
+ config.get_context_window_for_model("ZhipuAI/GLM-5.3", "/no/such/config.json"),
307
+ config.DEFAULT_CONTEXT_WINDOW,
308
+ )
309
+ # provider prefix must not break other families either
310
+ self.assertEqual(
311
+ config.get_context_window_for_model(
312
+ "DeepSeek/deepseek-v4-flash", "/no/such/config.json"
313
+ ),
314
+ 1_000_000,
315
+ )
316
+
286
317
  def test_get_context_window_no_file(self):
287
318
  self.assertEqual(
288
319
  config.get_context_window_for_model("deepseek-v4", "/no/such/file.json"),
@@ -808,6 +808,37 @@ class TestModelSwitching(unittest.TestCase):
808
808
  self.assertIn("deepseek", msg)
809
809
  self.assertIn("glm", msg)
810
810
 
811
+ def test_switch_model_re_resolves_context_window(self):
812
+ """Switching models must make the next context-window access
813
+ resolve for the NEW model: the ratio computation divides by
814
+ the new model's window, not the old one."""
815
+ from python_agent_harness.client import Client
816
+
817
+ with tempfile.TemporaryDirectory() as d:
818
+ p = os.path.join(d, "config.json")
819
+ with open(p, "w", encoding="utf-8") as f:
820
+ f.write('{"context_windows": {"deepseek-v4": 1000000}}')
821
+ client = Client(base_url="http://x/v1", api_key="k", model="gpt-5-mini", config_path=p)
822
+ self.addCleanup(client.close)
823
+ session = RecordingSession(model_profiles={"deepseek": {"model": "deepseek-v4-flash"}})
824
+ session.client = client
825
+ self.assertEqual(client.context_window, 128_000)
826
+ success, _ = session.switch_model("deepseek")
827
+ self.assertTrue(success)
828
+ self.assertEqual(client.context_window, 1_000_000)
829
+
830
+ def test_switch_model_resets_calibrator(self):
831
+ """Switching models must drop the token-calibration factor: it
832
+ was tuned to the previous model's tokenizer and would skew the
833
+ first context estimates for the new model."""
834
+ session = RecordingSession(model_profiles={"deepseek": {"model": "deepseek-v4-flash"}})
835
+ session.calibrator.factor = 2.5
836
+ session.calibrator.last_raw_estimate = 1234
837
+ success, _ = session.switch_model("deepseek")
838
+ self.assertTrue(success)
839
+ self.assertEqual(session.calibrator.factor, 1.0)
840
+ self.assertIsNone(session.calibrator.last_raw_estimate)
841
+
811
842
  def test_switch_model_preserves_conversation_history(self):
812
843
  """Switching models does not clear conversation history."""
813
844
  from python_agent_harness.models import Message
@@ -55,7 +55,7 @@ class TestTokenizer(unittest.TestCase):
55
55
  built-in table (matched before CONTEXT_WINDOWS)."""
56
56
  with tempfile.TemporaryDirectory() as d:
57
57
  p = Path(d) / "config.json"
58
- p.write_text('{"context_windows": {"deepseek-v4*": 2000000}}', encoding="utf-8")
58
+ p.write_text('{"context_windows": {"deepseek-v4": 2000000}}', encoding="utf-8")
59
59
  self.assertEqual(context_window_for("deepseek-v4-flash", str(p)), 2_000_000)
60
60
  # models the override doesn't cover still use the table
61
61
  self.assertEqual(context_window_for("gpt-5-mini", str(p)), 128_000)
@@ -77,6 +77,19 @@ class TestTokenizer(unittest.TestCase):
77
77
  c2.update(500)
78
78
  self.assertEqual(c2.factor, 1.0)
79
79
 
80
+ def test_calibrator_reset(self):
81
+ """reset() drops the factor and raw estimate: after a model
82
+ switch the next estimate is uncalibrated (identity), and the
83
+ stale factor tuned to the old tokenizer is gone."""
84
+ c = TokenCalibrator()
85
+ c.last_raw_estimate = 1000
86
+ c.update(3000)
87
+ self.assertEqual(c.factor, 3.0)
88
+ c.reset()
89
+ self.assertEqual(c.factor, 1.0)
90
+ self.assertIsNone(c.last_raw_estimate)
91
+ self.assertEqual(c.calibrate(1000), 1000)
92
+
80
93
  def test_payload_tokens(self):
81
94
  msgs = [
82
95
  {"role": "user", "content": "hello"},