Skip to content

Commit f980720

Browse files
committed
Resolve context window per access; reset calibrator on model switch; bump to 1.5.4.2
1 parent 53f10c7 commit f980720

9 files changed

Lines changed: 105 additions & 30 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-agent-harness"
7-
version = "1.5.4.1"
7+
version = "1.5.4.2"
88
description = "A lightweight, hackable mini-OpenCode written in Python."
99
readme = "README.md"
1010
requires-python = ">=3.11"

python_agent_harness/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .models import AgentMode, Message, ToolCall, ToolSpec
66
from .session import Session
77

8-
__version__ = "1.5.4.1"
8+
__version__ = "1.5.4.2"
99

1010
__all__ = [
1111
"Session",

python_agent_harness/client.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def __init__(
200200
) -> None:
201201
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
202202
self.api_key = api_key or _default_api_key()
203-
self.model = model or config.DEFAULT_MODEL
203+
self.model: str = model or config.DEFAULT_MODEL
204204
self.timeout = timeout
205205
self.verify = verify if verify is not None else _resolve_ca_bundle()
206206
self.retry_max = config.API_RETRY_MAX if retry_max is None else retry_max
@@ -212,8 +212,6 @@ def __init__(
212212
)
213213
self._config_path = config_path
214214
self._http = httpx.Client(timeout=timeout, verify=self.verify)
215-
# Resolve context window for this model (API discovery + fallbacks)
216-
self._context_window = None # lazy-loaded
217215
# True while the in-flight request was aborted (Ctrl-C): a
218216
# connection error on an aborted request must NOT be retried —
219217
# the user asked to stop. Cleared at the start of each chat()
@@ -233,19 +231,19 @@ def context_window(self) -> int:
233231
234232
Resolution order: config-file ``context_windows`` overrides
235233
(via ``config.get_context_window_for_model``) -> CONTEXT_WINDOWS
236-
pattern match -> DEFAULT_CONTEXT_WINDOW. The resolved value is
237-
cached for the life of the client.
234+
pattern match -> DEFAULT_CONTEXT_WINDOW. Resolved on every
235+
access (no caching), so a runtime model switch or config-file
236+
edit takes effect immediately; a malformed config falls back to
237+
the default for that access and recovers once the file is fixed.
238238
"""
239-
if self._context_window is None:
240-
try:
241-
self._context_window = config.get_context_window_for_model(
242-
self.model, config_path=self._config_path
243-
)
244-
except Exception:
245-
# a malformed context_windows section must not break
246-
# the loop: cache the safe default
247-
self._context_window = config.DEFAULT_CONTEXT_WINDOW
248-
return self._context_window
239+
try:
240+
return config.get_context_window_for_model(
241+
self.model, config_path=self._config_path
242+
)
243+
except Exception:
244+
# a malformed context_windows section must not break the
245+
# loop: use the safe default, retry on the next access
246+
return config.DEFAULT_CONTEXT_WINDOW
249247

250248
def close(self) -> None:
251249
self._http.close()

python_agent_harness/context_manager.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ class ContextManager:
2121
from the loop's delegate so the call site keeps resolving them
2222
through the ``agent`` module namespace (tests patch
2323
``python_agent_harness.agent.estimate_payload_tokens``). The
24-
context window comes from the session's client (cached, config-
25-
file aware); ``context_window_for`` is only the fallback for
26-
clients without the property.
24+
context window comes from the session's client (config-file aware,
25+
resolved per access); ``context_window_for`` is only the fallback
26+
for clients without the property.
2727
"""
2828

2929
def __init__(self, loop: Any) -> None:
@@ -42,9 +42,9 @@ def update_context_ratio(
4242
)
4343
loop.session.calibrator.last_raw_estimate = raw
4444
calibrated = loop.session.calibrator.calibrate(raw)
45-
# Prefer the client's cached window (config overrides ->
46-
# patterns -> default); fall back to the static resolver for
47-
# clients without the property (test doubles).
45+
# Prefer the client's window (config overrides -> patterns ->
46+
# default, resolved per access); fall back to the static
47+
# resolver for clients without the property (test doubles).
4848
client = loop.session.client
4949
window = getattr(client, "context_window", None)
5050
if window is None:

python_agent_harness/session.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,9 @@ def switch_model(self, name: str) -> tuple[bool, str]:
687687
self.client.model = merged["model"]
688688
self.model = merged["model"]
689689
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()
690693
self.backend = merged["backend"]
691694
self.store.backend = merged["backend"]
692695
self.temperature = merged["temperature"]

python_agent_harness/token_estimator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ def __init__(self) -> None:
6868
self.factor = 1.0
6969
self.last_raw_estimate: int | None = None
7070

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+
7177
def update(self, actual_input: int | None) -> None:
7278
raw = self.last_raw_estimate
7379
if actual_input is None or actual_input <= 0 or raw is None or raw <= 0:

tests/test_client.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,25 +1694,38 @@ def fake_sync(payload, on_delta, on_tool_call, usage):
16941694

16951695
class TestContextWindow(unittest.TestCase):
16961696
"""Client.context_window: config-file overrides -> CONTEXT_WINDOWS
1697-
patterns -> DEFAULT_CONTEXT_WINDOW. The resolved value is cached
1698-
for the life of the client."""
1697+
patterns -> DEFAULT_CONTEXT_WINDOW. Resolved on every access (no
1698+
caching), so model switches and config edits take effect at once."""
16991699

17001700
def _client(self, model: str, config_path: str | None = None) -> Client:
17011701
c = Client(base_url="http://x/v1", api_key="k", model=model, config_path=config_path)
17021702
self.addCleanup(c.close)
17031703
return c
17041704

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+
17051718
def test_config_file_override_wins(self):
17061719
"""A context_windows entry in the config file beats the
1707-
built-in table, and the resolved value is cached."""
1720+
built-in table, and edits are picked up on the next access."""
17081721
with tempfile.TemporaryDirectory() as d:
17091722
p = Path(d) / "config.json"
17101723
p.write_text('{"context_windows": {"fake*": 999999}}', encoding="utf-8")
17111724
c = self._client(model="fake", config_path=str(p))
17121725
self.assertEqual(c.context_window, 999_999)
1713-
# cached: a later config change must not affect the window
1726+
# no caching: a later config change takes effect immediately
17141727
p.write_text('{"context_windows": {"fake*": 111111}}', encoding="utf-8")
1715-
self.assertEqual(c.context_window, 999_999)
1728+
self.assertEqual(c.context_window, 111_111)
17161729

17171730
def test_config_file_wildcard_matching(self):
17181731
"""Config-file patterns support fnmatch wildcards, first match
@@ -1763,11 +1776,16 @@ def test_missing_config_file_uses_table(self):
17631776

17641777
def test_malformed_config_file_falls_back_to_default(self):
17651778
"""A broken context_windows section must not break the loop:
1766-
the client caches DEFAULT_CONTEXT_WINDOW."""
1779+
the client uses DEFAULT_CONTEXT_WINDOW for that access, and
1780+
recovers once the file is fixed (failures are not cached)."""
17671781
with tempfile.TemporaryDirectory() as d:
17681782
p = Path(d) / "config.json"
17691783
p.write_text('{"context_windows": {"fake": "not-a-number"}}', encoding="utf-8")
1770-
self.assertEqual(self._client("fake", str(p)).context_window, 128_000)
1784+
c = self._client("fake", str(p))
1785+
self.assertEqual(c.context_window, 128_000)
1786+
# no caching of the failure: a fixed file is picked up
1787+
p.write_text('{"context_windows": {"fake*": 999999}}', encoding="utf-8")
1788+
self.assertEqual(c.context_window, 999_999)
17711789

17721790

17731791
if __name__ == "__main__":

tests/test_session.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,43 @@ def test_switch_model_unknown_name_fails(self):
808808
self.assertIn("deepseek", msg)
809809
self.assertIn("glm", msg)
810810

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(
822+
base_url="http://x/v1", api_key="k", model="gpt-5-mini", config_path=p
823+
)
824+
self.addCleanup(client.close)
825+
session = RecordingSession(
826+
model_profiles={"deepseek": {"model": "deepseek-v4-flash"}}
827+
)
828+
session.client = client
829+
self.assertEqual(client.context_window, 128_000)
830+
success, _ = session.switch_model("deepseek")
831+
self.assertTrue(success)
832+
self.assertEqual(client.context_window, 1_000_000)
833+
834+
def test_switch_model_resets_calibrator(self):
835+
"""Switching models must drop the token-calibration factor: it
836+
was tuned to the previous model's tokenizer and would skew the
837+
first context estimates for the new model."""
838+
session = RecordingSession(
839+
model_profiles={"deepseek": {"model": "deepseek-v4-flash"}}
840+
)
841+
session.calibrator.factor = 2.5
842+
session.calibrator.last_raw_estimate = 1234
843+
success, _ = session.switch_model("deepseek")
844+
self.assertTrue(success)
845+
self.assertEqual(session.calibrator.factor, 1.0)
846+
self.assertIsNone(session.calibrator.last_raw_estimate)
847+
811848
def test_switch_model_preserves_conversation_history(self):
812849
"""Switching models does not clear conversation history."""
813850
from python_agent_harness.models import Message

tests/test_token_estimator.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ def test_calibrator(self):
7777
c2.update(500)
7878
self.assertEqual(c2.factor, 1.0)
7979

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+
8093
def test_payload_tokens(self):
8194
msgs = [
8295
{"role": "user", "content": "hello"},

0 commit comments

Comments
 (0)