python-agent-harness 1.5.4__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/python_agent_harness.egg-info → python_agent_harness-1.5.4.2}/PKG-INFO +8 -2
  2. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/README.md +6 -1
  3. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/pyproject.toml +3 -1
  4. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/cli.py +8 -0
  6. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/client.py +20 -3
  7. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/commands.py +1 -2
  8. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/config.py +82 -5
  9. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/context_manager.py +11 -2
  10. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/client.py +2 -4
  11. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/manager.py +0 -2
  12. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/persistence.py +1 -2
  13. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/session.py +3 -0
  14. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/token_estimator.py +18 -8
  15. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2/python_agent_harness.egg-info}/PKG-INFO +8 -2
  16. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_client.py +111 -1
  17. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_config.py +143 -0
  18. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_session.py +31 -0
  19. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_token_estimator.py +44 -0
  20. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/LICENSE +0 -0
  21. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/__main__.py +0 -0
  22. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/agent.py +0 -0
  23. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/diffrender.py +0 -0
  24. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/__init__.py +0 -0
  25. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/config.py +0 -0
  26. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/models.py +0 -0
  27. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/planmode.py +0 -0
  28. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/agent.md +0 -0
  29. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/build-switch.md +0 -0
  30. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/commands/explain.md +0 -0
  31. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/compact.md +0 -0
  32. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/initialize.md +0 -0
  33. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan-mode.md +0 -0
  34. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan.md +0 -0
  35. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/review.md +0 -0
  36. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/subagent.md +0 -0
  37. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/summary.md +0 -0
  38. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  39. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/title.md +0 -0
  40. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts.py +0 -0
  41. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/subagent.py +0 -0
  42. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tool_runner.py +0 -0
  43. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/__init__.py +0 -0
  44. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/agent_tool.py +0 -0
  45. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/base.py +0 -0
  46. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/bash.py +0 -0
  47. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/edit.py +0 -0
  48. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/filesystem.py +0 -0
  49. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/glob.py +0 -0
  50. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/grep.py +0 -0
  51. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/insert.py +0 -0
  52. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mcp.py +0 -0
  53. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mkdir.py +0 -0
  54. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/planexit.py +0 -0
  55. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/question.py +0 -0
  56. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/read.py +0 -0
  57. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/skill.py +0 -0
  58. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/todo.py +0 -0
  59. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/write.py +0 -0
  60. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/__init__.py +0 -0
  61. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/commands.py +0 -0
  62. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/core.py +0 -0
  63. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/input.py +0 -0
  64. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/render.py +0 -0
  65. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/SOURCES.txt +0 -0
  66. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  67. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/entry_points.txt +0 -0
  68. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/requires.txt +0 -0
  69. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/top_level.txt +0 -0
  70. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/setup.cfg +0 -0
  71. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_agents_md.py +0 -0
  72. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_cli.py +0 -0
  73. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_commands.py +0 -0
  74. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_concurrent_subagents.py +0 -0
  75. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_context_rules.py +0 -0
  76. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_diffrender.py +0 -0
  77. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_filesystem.py +0 -0
  78. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_invariants.py +0 -0
  79. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_mcp.py +0 -0
  80. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_models.py +0 -0
  81. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_persistence.py +0 -0
  82. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_planmode.py +0 -0
  83. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_prompts.py +0 -0
  84. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_scenarios.py +0 -0
  85. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_subagent.py +0 -0
  86. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_subagent_isolation.py +0 -0
  87. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_todos_scope.py +0 -0
  88. {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_tools_misc.py +0 -0
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4
3
+ Version: 1.5.4.2
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
+ License-Expression: MIT
6
7
  Requires-Python: >=3.11
7
8
  Description-Content-Type: text/markdown
8
9
  License-File: LICENSE
@@ -24,7 +25,7 @@ Dynamic: license-file
24
25
 
25
26
  # python-agent-harness
26
27
 
27
- **A lightweight, hackable mini-OpenCode written in Python.**
28
+ **A lightweight, hackable mini-OpenCode written in Python.**
28
29
  FSM-driven execution · OpenAI-compatible · built for daily use and easy customization
29
30
 
30
31
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
@@ -144,6 +145,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
144
145
  "model": "qwen3.5-coder"
145
146
  }
146
147
  },
148
+ "context_windows": {
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
+ },
147
152
  "subagent_llm": {
148
153
  "profile": null,
149
154
  "base_url": null,
@@ -183,6 +188,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
183
188
 
184
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`.
185
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 substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
186
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.
187
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.
188
194
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # python-agent-harness
4
4
 
5
- **A lightweight, hackable mini-OpenCode written in Python.**
5
+ **A lightweight, hackable mini-OpenCode written in Python.**
6
6
  FSM-driven execution · OpenAI-compatible · built for daily use and easy customization
7
7
 
8
8
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
@@ -122,6 +122,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
122
122
  "model": "qwen3.5-coder"
123
123
  }
124
124
  },
125
+ "context_windows": {
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
+ },
125
129
  "subagent_llm": {
126
130
  "profile": null,
127
131
  "base_url": null,
@@ -161,6 +165,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
161
165
 
162
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`.
163
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 substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
164
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.
165
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.
166
171
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -4,13 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.4"
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"
11
11
  authors = [
12
12
  { name = "huming" }
13
13
  ]
14
+ license = 'MIT'
15
+ license-files = ["LICENSE"]
14
16
  dependencies = [
15
17
  "rich>=13.0",
16
18
  "httpx>=0.27",
@@ -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"
8
+ __version__ = "1.5.4.2"
9
9
 
10
10
  __all__ = [
11
11
  "Session",
@@ -221,6 +221,14 @@ def cmd_config(args: argparse.Namespace) -> int:
221
221
  print(f" {name}: model={model_name}, base_url={base_url}")
222
222
  else:
223
223
  print("models: (none configured — add a 'models' section to use /model)")
224
+ # Show context-window overrides
225
+ context_windows = config.load_context_windows_config(args.path)
226
+ if context_windows:
227
+ print("context_windows:")
228
+ for pattern, size in context_windows:
229
+ print(f" {pattern}: {size}")
230
+ else:
231
+ print("context_windows: (none configured — built-in table in config.py applies)")
224
232
  return 0
225
233
 
226
234
 
@@ -11,6 +11,7 @@ import contextlib
11
11
  import json
12
12
  import os
13
13
  import random
14
+ import socket as _socket
14
15
  import threading
15
16
  import time
16
17
  import uuid
@@ -199,7 +200,7 @@ class Client:
199
200
  ) -> None:
200
201
  self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
201
202
  self.api_key = api_key or _default_api_key()
202
- self.model = model or config.DEFAULT_MODEL
203
+ self.model: str = model or config.DEFAULT_MODEL
203
204
  self.timeout = timeout
204
205
  self.verify = verify if verify is not None else _resolve_ca_bundle()
205
206
  self.retry_max = config.API_RETRY_MAX if retry_max is None else retry_max
@@ -224,6 +225,24 @@ class Client:
224
225
  else (_llm_log_path() if config.LLM_LOG_ENABLED else None)
225
226
  )
226
227
 
228
+ @property
229
+ def context_window(self) -> int:
230
+ """Get the context window for this model.
231
+
232
+ Resolution order: config-file ``context_windows`` overrides
233
+ (via ``config.get_context_window_for_model``) -> CONTEXT_WINDOWS
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
+ """
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
245
+
227
246
  def close(self) -> None:
228
247
  self._http.close()
229
248
 
@@ -810,8 +829,6 @@ def _abort_inflight_sockets(client: httpx.Client) -> None:
810
829
  down an idle socket is harmless (the pool is closed right after
811
830
  anyway); any failure is ignored (best effort).
812
831
  """
813
- import socket as _socket
814
-
815
832
  pool = getattr(getattr(client, "_transport", None), "_pool", None)
816
833
  for conn in getattr(pool, "_connections", None) or ():
817
834
  try:
@@ -17,6 +17,7 @@ Tool availability per command:
17
17
  from __future__ import annotations
18
18
 
19
19
  import re
20
+ import subprocess
20
21
  from collections.abc import Callable
21
22
  from pathlib import Path
22
23
  from typing import Any
@@ -35,8 +36,6 @@ def _substitute(text: str, path: str, extra: str | None) -> str:
35
36
 
36
37
  def _project_root(cwd: str) -> str:
37
38
  """Best-effort project root (git dir or parent with AGENTS.md)."""
38
- import subprocess
39
-
40
39
  d = Path(cwd).resolve()
41
40
  try:
42
41
  out = subprocess.run(
@@ -8,10 +8,8 @@ from __future__ import annotations
8
8
  import json
9
9
  import os
10
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING
12
11
 
13
- if TYPE_CHECKING:
14
- from .mcp.config import MCPConfig
12
+ from .mcp.config import MCPConfig
15
13
 
16
14
  # ---- context management -------------------------------------------------
17
15
  CONTEXT_TRIGGER = 0.70
@@ -223,6 +221,10 @@ CONFIG_TEMPLATE = """\
223
221
  "model": "gpt-5-mini"
224
222
  }}
225
223
  }},
224
+ "context_windows": {{
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
227
+ }},
226
228
  "subagent_llm": {{
227
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).",
228
230
  "profile": null,
@@ -428,8 +430,6 @@ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
428
430
  ``mcp`` SDK is only needed when servers are actually configured and
429
431
  connected — reading the config never requires it.
430
432
  """
431
- from .mcp.config import MCPConfig
432
-
433
433
  data = _read_config(path)
434
434
  section = data.get("mcp") or {}
435
435
  if not isinstance(section, dict):
@@ -462,3 +462,80 @@ def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]
462
462
 
463
463
  def mask_secret(value: str | None) -> str:
464
464
  return "****" if value else "(unset)"
465
+
466
+
467
+ def _match_context_window(model: str) -> int | None:
468
+ """Match model name against CONTEXT_WINDOWS entries (substring match).
469
+
470
+ Args:
471
+ model: The model ID to match (e.g., "gpt-5-mini", "claude-3-opus")
472
+
473
+ Returns:
474
+ The context window size if matched, None otherwise
475
+ """
476
+ lowered = model.lower()
477
+ for pattern, size in CONTEXT_WINDOWS:
478
+ if pattern.lower() in lowered:
479
+ return size
480
+ return None
481
+
482
+
483
+ def load_context_windows_config(
484
+ path: str | os.PathLike | None = None,
485
+ ) -> list[tuple[str, int]]:
486
+ """Load per-model context-window overrides from the config file.
487
+
488
+ Reads the ``context_windows`` object: a mapping of model names or
489
+ substrings (matched in file order, first match wins) to token
490
+ counts. Keys starting with ``_`` are comments and skipped. A
491
+ missing file, missing section, or unreadable JSON yields ``[]``
492
+ (callers fall back to the built-in table); a malformed section or
493
+ non-integer size raises ValueError so config errors surface.
494
+ """
495
+ try:
496
+ data = _read_config(path)
497
+ except ValueError:
498
+ return []
499
+ section = data.get("context_windows") or {}
500
+ if not isinstance(section, dict):
501
+ raise ValueError(f"config file {_config_path(path)}: context_windows must be an object")
502
+ entries: list[tuple[str, int]] = []
503
+ for pattern, size in section.items():
504
+ if pattern.startswith("_"):
505
+ continue
506
+ if isinstance(size, bool) or not isinstance(size, int):
507
+ raise ValueError(
508
+ f"config file {_config_path(path)}: context_windows.{pattern} must be an integer"
509
+ )
510
+ entries.append((pattern, size))
511
+ return entries
512
+
513
+
514
+ def get_context_window_for_model(
515
+ model: str,
516
+ config_path: str | os.PathLike | None = None,
517
+ ) -> int:
518
+ """Get the context window for MODEL: config-file overrides, then
519
+ the built-in table, then the default.
520
+
521
+ The config file's ``context_windows`` object (user overrides) is
522
+ consulted first (substring match over its keys, first match wins,
523
+ case-insensitive); then the CONTEXT_WINDOWS table in config.py;
524
+ then DEFAULT_CONTEXT_WINDOW.
525
+
526
+ Args:
527
+ model: The model ID to look up (e.g. "deepseek-v4-flash")
528
+ config_path: Optional path to the config file; defaults to the
529
+ standard config location (config.json).
530
+
531
+ Returns:
532
+ The context window size as an integer.
533
+ """
534
+ lowered = model.lower()
535
+ for pattern, size in load_context_windows_config(config_path):
536
+ if pattern.lower() in lowered:
537
+ return size
538
+ matched = _match_context_window(model)
539
+ if matched is not None:
540
+ return matched
541
+ return DEFAULT_CONTEXT_WINDOW
@@ -20,7 +20,10 @@ class ContextManager:
20
20
  ``update_context_ratio`` receives the two token-estimator functions
21
21
  from the loop's delegate so the call site keeps resolving them
22
22
  through the ``agent`` module namespace (tests patch
23
- ``python_agent_harness.agent.estimate_payload_tokens``).
23
+ ``python_agent_harness.agent.estimate_payload_tokens``). The
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.
24
27
  """
25
28
 
26
29
  def __init__(self, loop: Any) -> None:
@@ -39,7 +42,13 @@ class ContextManager:
39
42
  )
40
43
  loop.session.calibrator.last_raw_estimate = raw
41
44
  calibrated = loop.session.calibrator.calibrate(raw)
42
- window = context_window_for(loop.session.model)
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
+ client = loop.session.client
49
+ window = getattr(client, "context_window", None)
50
+ if window is None:
51
+ window = context_window_for(loop.session.model)
43
52
  loop.session.context_ratio = calibrated / float(window)
44
53
  loop.session.notify("context")
45
54
 
@@ -18,10 +18,12 @@ content-block dicts), ``structured_content`` and ``is_error``.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import contextlib
21
22
  import os
22
23
  from typing import Any
23
24
 
24
25
  try:
26
+ import httpx2 # shipped as part of the mcp SDK
25
27
  from mcp import Client
26
28
  from mcp.client.sse import sse_client
27
29
  from mcp.client.stdio import StdioServerParameters, stdio_client
@@ -93,8 +95,6 @@ class MCPClient:
93
95
  assert cfg.url is not None
94
96
  if cfg.transport == "streamable-http":
95
97
  if cfg.headers:
96
- import httpx2 # shipped as part of the mcp SDK
97
-
98
98
  self._http_client = httpx2.AsyncClient(headers=cfg.headers)
99
99
  return streamable_http_client(cfg.url, http_client=self._http_client)
100
100
  return streamable_http_client(cfg.url)
@@ -117,8 +117,6 @@ class MCPClient:
117
117
 
118
118
  async def close(self) -> None:
119
119
  """Tear down the connection (best effort, never raises)."""
120
- import contextlib
121
-
122
120
  client, self._client = self._client, None
123
121
  if client is not None:
124
122
  with contextlib.suppress(Exception): # teardown noise is not an error
@@ -200,8 +200,6 @@ class MCPManager:
200
200
 
201
201
  def disconnect(self, name: str) -> None:
202
202
  """Disconnect one server and drop its discovered tools."""
203
- import contextlib
204
-
205
203
  client = self._clients.pop(name, None)
206
204
  if client is None:
207
205
  return
@@ -12,6 +12,7 @@ Ported from gptel-agent-harness-session.el.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import ast
15
16
  import os
16
17
  import re
17
18
  import threading
@@ -284,8 +285,6 @@ class SessionPersistence:
284
285
 
285
286
  def _parse_metadata_value(value: str) -> str:
286
287
  """Parse a repr()-style metadata value, mirroring elisp read-from-string."""
287
- import ast
288
-
289
288
  try:
290
289
  parsed = ast.literal_eval(value)
291
290
  if isinstance(parsed, str):
@@ -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"]
@@ -8,6 +8,7 @@ to reduce drift.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import os
11
12
  import re
12
13
 
13
14
  from . import config
@@ -39,17 +40,20 @@ def estimate_tokens(text: str) -> int:
39
40
  return round(latin / 4.0 + cjk / 2.0)
40
41
 
41
42
 
42
- def context_window_for(model: str) -> int:
43
+ def context_window_for(model: str, config_path: str | os.PathLike | None = None) -> int:
43
44
  """Return the context window for MODEL, or a safe fallback.
44
45
 
45
- Entries are matched in order using substring matching, so more
46
- specific patterns must come before general ones (see config).
46
+ Delegates to ``config.get_context_window_for_model``: config-file
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
+
51
+ Args:
52
+ model: The model ID to look up.
53
+ config_path: Optional config file path; defaults to the
54
+ standard config location (config.json).
47
55
  """
48
- lowered = model.lower()
49
- for pattern, size in config.CONTEXT_WINDOWS:
50
- if pattern in lowered:
51
- return size
52
- return config.DEFAULT_CONTEXT_WINDOW
56
+ return config.get_context_window_for_model(model, config_path=config_path)
53
57
 
54
58
 
55
59
  class TokenCalibrator:
@@ -64,6 +68,12 @@ class TokenCalibrator:
64
68
  self.factor = 1.0
65
69
  self.last_raw_estimate: int | None = None
66
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
+
67
77
  def update(self, actual_input: int | None) -> None:
68
78
  raw = self.last_raw_estimate
69
79
  if actual_input is None or actual_input <= 0 or raw is None or raw <= 0:
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.4
3
+ Version: 1.5.4.2
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author: huming
6
+ License-Expression: MIT
6
7
  Requires-Python: >=3.11
7
8
  Description-Content-Type: text/markdown
8
9
  License-File: LICENSE
@@ -24,7 +25,7 @@ Dynamic: license-file
24
25
 
25
26
  # python-agent-harness
26
27
 
27
- **A lightweight, hackable mini-OpenCode written in Python.**
28
+ **A lightweight, hackable mini-OpenCode written in Python.**
28
29
  FSM-driven execution · OpenAI-compatible · built for daily use and easy customization
29
30
 
30
31
  [![CI](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/beacoder/python-agent-harness/actions/workflows/ci.yml)
@@ -144,6 +145,10 @@ All LLM settings live in a single JSON configuration file. Environment variables
144
145
  "model": "qwen3.5-coder"
145
146
  }
146
147
  },
148
+ "context_windows": {
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
+ },
147
152
  "subagent_llm": {
148
153
  "profile": null,
149
154
  "base_url": null,
@@ -183,6 +188,7 @@ All LLM settings live in a single JSON configuration file. Environment variables
183
188
 
184
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`.
185
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 substrings (e.g., `deepseek-v4`); matched in file order, first match wins. Overrides the built-in `CONTEXT_WINDOWS` table in `config.py`.
186
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.
187
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.
188
194
  - **`mcp.servers`** — MCP server configuration. Requires the `[mcp]` extra. Each server supports `transport`, `command`, `args`, `env`, `url`, `headers`, `parallel`, `timeout`, and `enabled`.
@@ -6,6 +6,7 @@ import sys
6
6
  import tempfile
7
7
  import time
8
8
  import unittest
9
+ from pathlib import Path
9
10
  from unittest import mock
10
11
 
11
12
  import httpx
@@ -27,13 +28,14 @@ def make_client(
27
28
  retry_max: int | None = None,
28
29
  retry_base_delay: float | None = None,
29
30
  retry_max_delay: float | None = None,
31
+ model: str = "fake",
30
32
  ) -> Client:
31
33
  srv = serve()
32
34
  host, port = srv.server_address
33
35
  c = Client(
34
36
  base_url=f"http://{host}:{port}/v1",
35
37
  api_key="test",
36
- model="fake",
38
+ model=model,
37
39
  retry_max=retry_max,
38
40
  retry_base_delay=retry_base_delay,
39
41
  retry_max_delay=retry_max_delay,
@@ -1690,5 +1692,113 @@ class TestAuthRefreshOn401(unittest.TestCase):
1690
1692
  c.close()
1691
1693
 
1692
1694
 
1695
+ class TestContextWindow(unittest.TestCase):
1696
+ """Client.context_window: config-file overrides -> CONTEXT_WINDOWS
1697
+ patterns -> DEFAULT_CONTEXT_WINDOW. Resolved on every access (no
1698
+ caching), so model switches and config edits take effect at once."""
1699
+
1700
+ def _client(self, model: str, config_path: str | None = None) -> Client:
1701
+ c = Client(base_url="http://x/v1", api_key="k", model=model, config_path=config_path)
1702
+ self.addCleanup(c.close)
1703
+ return c
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
+
1718
+ def test_config_file_override_wins(self):
1719
+ """A context_windows entry in the config file beats the
1720
+ built-in table, and edits are picked up on the next access."""
1721
+ with tempfile.TemporaryDirectory() as d:
1722
+ p = Path(d) / "config.json"
1723
+ p.write_text('{"context_windows": {"fake": 999999}}', encoding="utf-8")
1724
+ c = self._client(model="fake", config_path=str(p))
1725
+ 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)
1729
+
1730
+ def test_config_file_substring_matching(self):
1731
+ """Config-file keys are matched as substrings, first match
1732
+ wins in file order."""
1733
+ with tempfile.TemporaryDirectory() as d:
1734
+ p = Path(d) / "config.json"
1735
+ p.write_text(
1736
+ '{"context_windows": {"gpt-4": 500000, "gpt-4-turbo": 300000}}',
1737
+ encoding="utf-8",
1738
+ )
1739
+ # "gpt-4-turbo" hits the FIRST entry ("gpt-4")
1740
+ self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 500_000)
1741
+ self.assertEqual(self._client("gpt-4o", str(p)).context_window, 500_000)
1742
+
1743
+ def test_unknown_model_in_config_file_falls_to_table(self):
1744
+ """Config-file overrides that don't match fall through to the
1745
+ built-in CONTEXT_WINDOWS table."""
1746
+ with tempfile.TemporaryDirectory() as d:
1747
+ p = Path(d) / "config.json"
1748
+ p.write_text('{"context_windows": {"deepseek-v4": 1000000}}', encoding="utf-8")
1749
+ self.assertEqual(self._client("gpt-5-mini", str(p)).context_window, 128_000)
1750
+ self.assertEqual(self._client("deepseek-v4-flash", str(p)).context_window, 1_000_000)
1751
+
1752
+ def test_fallback_to_pattern_match(self):
1753
+ """No config-file overrides -> CONTEXT_WINDOWS substring match."""
1754
+ with tempfile.TemporaryDirectory() as d:
1755
+ p = Path(d) / "config.json"
1756
+ p.write_text('{"llm": {"model": "x"}}', encoding="utf-8")
1757
+ self.assertEqual(self._client("gpt-4-turbo", str(p)).context_window, 128_000)
1758
+
1759
+ def test_fallback_to_default(self):
1760
+ """No config-file overrides and no table match ->
1761
+ DEFAULT_CONTEXT_WINDOW."""
1762
+ with tempfile.TemporaryDirectory() as d:
1763
+ p = Path(d) / "config.json"
1764
+ p.write_text('{"llm": {"model": "x"}}', encoding="utf-8")
1765
+ self.assertEqual(
1766
+ self._client("totally-unknown-model", str(p)).context_window,
1767
+ config.DEFAULT_CONTEXT_WINDOW,
1768
+ )
1769
+
1770
+ def test_missing_config_file_uses_table(self):
1771
+ """No config file at all -> CONTEXT_WINDOWS table."""
1772
+ self.assertEqual(
1773
+ self._client("deepseek-v4-flash", "/no/such/config.json").context_window,
1774
+ 1_000_000,
1775
+ )
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
+
1789
+ def test_malformed_config_file_falls_back_to_default(self):
1790
+ """A broken context_windows section must not break the loop:
1791
+ the client uses DEFAULT_CONTEXT_WINDOW for that access, and
1792
+ recovers once the file is fixed (failures are not cached)."""
1793
+ with tempfile.TemporaryDirectory() as d:
1794
+ p = Path(d) / "config.json"
1795
+ p.write_text('{"context_windows": {"fake": "not-a-number"}}', encoding="utf-8")
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)
1801
+
1802
+
1693
1803
  if __name__ == "__main__":
1694
1804
  unittest.main()
@@ -180,6 +180,149 @@ class TestConfigFile(unittest.TestCase):
180
180
  self.assertIn("reasoning_effort", config.CONFIG_TEMPLATE)
181
181
  self.assertIn('"stream"', config.CONFIG_TEMPLATE)
182
182
  self.assertIn('"subagent_llm"', config.CONFIG_TEMPLATE)
183
+ self.assertIn('"context_windows"', config.CONFIG_TEMPLATE)
184
+
185
+
186
+ class TestContextWindowsConfig(unittest.TestCase):
187
+ """Config-file context-window overrides: loaded from the
188
+ ``context_windows`` object, matched before the built-in table."""
189
+
190
+ def setUp(self):
191
+ self._saved = {k: os.environ.get(k) for k in ENV_KEYS}
192
+ for k in ENV_KEYS:
193
+ os.environ.pop(k, None)
194
+
195
+ def tearDown(self):
196
+ for k, v in self._saved.items():
197
+ if v is None:
198
+ os.environ.pop(k, None)
199
+ else:
200
+ os.environ[k] = v
201
+
202
+ def test_missing_file_returns_empty(self):
203
+ self.assertEqual(config.load_context_windows_config("/no/such/file.json"), [])
204
+
205
+ def test_empty_section_returns_empty(self):
206
+ with tempfile.TemporaryDirectory() as d:
207
+ p = Path(d) / "config.json"
208
+ p.write_text('{"llm": {"model": "m"}}', encoding="utf-8")
209
+ self.assertEqual(config.load_context_windows_config(p), [])
210
+
211
+ def test_bad_json_returns_empty(self):
212
+ with tempfile.TemporaryDirectory() as d:
213
+ p = Path(d) / "config.json"
214
+ p.write_text("not {valid json", encoding="utf-8")
215
+ self.assertEqual(config.load_context_windows_config(p), [])
216
+
217
+ def test_overrides_loaded_in_order(self):
218
+ with tempfile.TemporaryDirectory() as d:
219
+ p = Path(d) / "config.json"
220
+ p.write_text(
221
+ '{"context_windows": {"deepseek-v4": 1000000, "gpt-5": 400000}}',
222
+ encoding="utf-8",
223
+ )
224
+ self.assertEqual(
225
+ config.load_context_windows_config(p),
226
+ [("deepseek-v4", 1000000), ("gpt-5", 400000)],
227
+ )
228
+
229
+ def test_comment_keys_skipped(self):
230
+ with tempfile.TemporaryDirectory() as d:
231
+ p = Path(d) / "config.json"
232
+ p.write_text(
233
+ '{"context_windows": {"_comment": "hi", "kimi": 256000}}',
234
+ encoding="utf-8",
235
+ )
236
+ self.assertEqual(config.load_context_windows_config(p), [("kimi", 256000)])
237
+
238
+ def test_section_must_be_object(self):
239
+ with tempfile.TemporaryDirectory() as d:
240
+ p = Path(d) / "config.json"
241
+ p.write_text('{"context_windows": "nope"}', encoding="utf-8")
242
+ with self.assertRaises(ValueError):
243
+ config.load_context_windows_config(p)
244
+
245
+ def test_size_must_be_integer(self):
246
+ with tempfile.TemporaryDirectory() as d:
247
+ p = Path(d) / "config.json"
248
+ p.write_text('{"context_windows": {"m": "big"}}', encoding="utf-8")
249
+ with self.assertRaises(ValueError):
250
+ config.load_context_windows_config(p)
251
+
252
+ def test_bool_size_rejected(self):
253
+ """True is an int subclass but not a valid token count."""
254
+ with tempfile.TemporaryDirectory() as d:
255
+ p = Path(d) / "config.json"
256
+ p.write_text('{"context_windows": {"m": true}}', encoding="utf-8")
257
+ with self.assertRaises(ValueError):
258
+ config.load_context_windows_config(p)
259
+
260
+ def test_get_context_window_precedence(self):
261
+ """Config-file override -> CONTEXT_WINDOWS -> default."""
262
+ with tempfile.TemporaryDirectory() as d:
263
+ p = Path(d) / "config.json"
264
+ p.write_text(
265
+ '{"context_windows": {"deepseek-v4": 1000000, "gpt-4-turbo": 300000}}',
266
+ encoding="utf-8",
267
+ )
268
+ self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
269
+ # config-file match beats the built-in table
270
+ self.assertEqual(config.get_context_window_for_model("gpt-4-turbo", p), 300000)
271
+ # built-in table still applies when no override matches
272
+ self.assertEqual(config.get_context_window_for_model("gpt-5-mini", p), 128000)
273
+ self.assertEqual(config.get_context_window_for_model("kimi-k2.7-0613", p), 256000)
274
+ # unknown model -> default
275
+ self.assertEqual(
276
+ config.get_context_window_for_model("unknown-model", p),
277
+ config.DEFAULT_CONTEXT_WINDOW,
278
+ )
279
+
280
+ def test_get_context_window_case_insensitive(self):
281
+ with tempfile.TemporaryDirectory() as d:
282
+ p = Path(d) / "config.json"
283
+ p.write_text('{"context_windows": {"DeepSeek-V4": 1000000}}', encoding="utf-8")
284
+ self.assertEqual(config.get_context_window_for_model("deepseek-v4-flash", p), 1000000)
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
+
317
+ def test_get_context_window_no_file(self):
318
+ self.assertEqual(
319
+ config.get_context_window_for_model("deepseek-v4", "/no/such/file.json"),
320
+ 1_000_000,
321
+ )
322
+ self.assertEqual(
323
+ config.get_context_window_for_model("totally-unknown", "/no/such/file.json"),
324
+ config.DEFAULT_CONTEXT_WINDOW,
325
+ )
183
326
 
184
327
 
185
328
  class TestSubagentLlmConfig(unittest.TestCase):
@@ -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
@@ -1,4 +1,7 @@
1
+ import os
2
+ import tempfile
1
3
  import unittest
4
+ from pathlib import Path
2
5
 
3
6
  from python_agent_harness.token_estimator import (
4
7
  TokenCalibrator,
@@ -11,6 +14,18 @@ from python_agent_harness.token_estimator import (
11
14
 
12
15
 
13
16
  class TestTokenizer(unittest.TestCase):
17
+ def setUp(self):
18
+ # pin the default config path to a nonexistent file so the
19
+ # tests never read the user's real config.json
20
+ self._saved_cfg = os.environ.get("PYTHON_AGENT_HARNESS_CONFIG")
21
+ os.environ["PYTHON_AGENT_HARNESS_CONFIG"] = "/no/such/harness-config.json"
22
+
23
+ def tearDown(self):
24
+ if self._saved_cfg is None:
25
+ os.environ.pop("PYTHON_AGENT_HARNESS_CONFIG", None)
26
+ else:
27
+ os.environ["PYTHON_AGENT_HARNESS_CONFIG"] = self._saved_cfg
28
+
14
29
  def test_empty_text_zero_tokens(self):
15
30
  self.assertEqual(estimate_tokens(""), 0)
16
31
 
@@ -26,10 +41,26 @@ class TestTokenizer(unittest.TestCase):
26
41
 
27
42
  def test_context_window_matching(self):
28
43
  self.assertEqual(context_window_for("deepseek-v4"), 1_000_000)
44
+ self.assertEqual(context_window_for("deepseek-v4-flash"), 1_000_000)
45
+ self.assertEqual(context_window_for("deepseek-v4-pro"), 1_000_000)
29
46
  self.assertEqual(context_window_for("gpt-5-mini"), 128_000)
47
+ self.assertEqual(context_window_for("gpt-5-pro"), 400_000)
30
48
  self.assertEqual(context_window_for("claude-sonnet"), 200_000)
49
+ self.assertEqual(context_window_for("qwen3.5-32b"), 131_072)
50
+ self.assertEqual(context_window_for("kimi-k2.7-0613"), 256_000)
31
51
  self.assertEqual(context_window_for("unknown-model"), 128_000)
32
52
 
53
+ def test_context_window_config_file_override(self):
54
+ """A context_windows section in the config file overrides the
55
+ built-in table (matched before CONTEXT_WINDOWS)."""
56
+ with tempfile.TemporaryDirectory() as d:
57
+ p = Path(d) / "config.json"
58
+ p.write_text('{"context_windows": {"deepseek-v4": 2000000}}', encoding="utf-8")
59
+ self.assertEqual(context_window_for("deepseek-v4-flash", str(p)), 2_000_000)
60
+ # models the override doesn't cover still use the table
61
+ self.assertEqual(context_window_for("gpt-5-mini", str(p)), 128_000)
62
+ self.assertEqual(context_window_for("unknown-model", str(p)), 128_000)
63
+
33
64
  def test_calibrator(self):
34
65
  c = TokenCalibrator()
35
66
  c.last_raw_estimate = 1000
@@ -46,6 +77,19 @@ class TestTokenizer(unittest.TestCase):
46
77
  c2.update(500)
47
78
  self.assertEqual(c2.factor, 1.0)
48
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
+
49
93
  def test_payload_tokens(self):
50
94
  msgs = [
51
95
  {"role": "user", "content": "hello"},