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.
- {python_agent_harness-1.5.4/python_agent_harness.egg-info → python_agent_harness-1.5.4.2}/PKG-INFO +8 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/README.md +6 -1
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/pyproject.toml +3 -1
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/cli.py +8 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/client.py +20 -3
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/commands.py +1 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/config.py +82 -5
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/context_manager.py +11 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/client.py +2 -4
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/manager.py +0 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/persistence.py +1 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/session.py +3 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/token_estimator.py +18 -8
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2/python_agent_harness.egg-info}/PKG-INFO +8 -2
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_client.py +111 -1
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_config.py +143 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_session.py +31 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_token_estimator.py +44 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/LICENSE +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/agent.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/diffrender.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/models.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/planmode.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/subagent.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tool_runner.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/__init__.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/bash.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/glob.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/grep.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/commands.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/input.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/render.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/SOURCES.txt +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/setup.cfg +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_agents_md.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_cli.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_commands.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_concurrent_subagents.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_context_rules.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_diffrender.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_filesystem.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_invariants.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_mcp.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_models.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_persistence.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_planmode.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_prompts.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_scenarios.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_subagent.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_subagent_isolation.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_todos_scope.py +0 -0
- {python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_tools_misc.py +0 -0
{python_agent_harness-1.5.4/python_agent_harness.egg-info → python_agent_harness-1.5.4.2}/PKG-INFO
RENAMED
|
@@ -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
|
[](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
|
[](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",
|
|
@@ -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:
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/commands.py
RENAMED
|
@@ -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
|
-
|
|
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
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/context_manager.py
RENAMED
|
@@ -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
|
|
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
|
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/client.py
RENAMED
|
@@ -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
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/persistence.py
RENAMED
|
@@ -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"]
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/token_estimator.py
RENAMED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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:
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2/python_agent_harness.egg-info}/PKG-INFO
RENAMED
|
@@ -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
|
[](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=
|
|
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"},
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/diffrender.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/mcp/config.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/planmode.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/agent.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/compact.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/plan.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/review.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/subagent.md
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/summary.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/prompts/title.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/subagent.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tool_runner.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/agent_tool.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/base.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/bash.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/edit.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/filesystem.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/glob.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/grep.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/insert.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mcp.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/mkdir.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/planexit.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/question.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/read.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/skill.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/todo.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tools/write.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/__init__.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/commands.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/core.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/input.py
RENAMED
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/python_agent_harness/tui/render.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_concurrent_subagents.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_agent_harness-1.5.4 → python_agent_harness-1.5.4.2}/tests/test_subagent_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|