feat(config): watch runtime configuration changes (#5026)

This commit is contained in:
chengyongru
2026-07-22 13:08:39 +08:00
committed by GitHub
parent 7bf7469d90
commit 3748f664b2
11 changed files with 190 additions and 7 deletions
+1
View File
@@ -98,6 +98,7 @@ class TestMaxMessagesInit:
initial = loop.runtime_resolver.runtime
assert replay_max_messages_for_context(initial.context_window_tokens) == 327
loop.runtime_resolver.invalidate()
refreshed = loop.llm_runtime()
assert replay_max_messages_for_context(refreshed.context_window_tokens) == FILE_MAX_MESSAGES
+49 -2
View File
@@ -177,12 +177,59 @@ def test_resolver_refreshes_provider_generation_for_next_default_turn() -> None:
admitted = resolver.current()
provider.generation = GenerationSettings(temperature=0.8, max_tokens=512)
refreshed = resolver.current(refresh=True)
refreshed = resolver.admit()
assert admitted.generation == GenerationSettings(0.2, 2048, None)
assert refreshed.generation == GenerationSettings(0.8, 512, None)
def test_resolver_admission_reloads_config_only_after_invalidation() -> None:
initial = _runtime()
refreshed_provider = _provider()
load_count = 0
def load_snapshot() -> ProviderSnapshot:
nonlocal load_count
load_count += 1
return ProviderSnapshot(
provider=refreshed_provider,
model="refreshed-model",
context_window_tokens=20_000,
signature=("refreshed-model", "auto"),
)
resolver = ModelRuntimeResolver(initial, provider_snapshot_loader=load_snapshot)
assert resolver.admit() is initial
assert load_count == 0
resolver.invalidate()
refreshed = resolver.admit()
assert refreshed.provider is refreshed_provider
assert refreshed.model == "refreshed-model"
assert resolver.admit() is refreshed
assert load_count == 1
def test_current_refresh_forces_config_reload() -> None:
initial = _runtime()
load_snapshot = MagicMock(
return_value=ProviderSnapshot(
provider=_provider(),
model="refreshed-model",
context_window_tokens=20_000,
signature=("refreshed-model", "auto"),
)
)
resolver = ModelRuntimeResolver(initial, provider_snapshot_loader=load_snapshot)
refreshed = resolver.current(refresh=True)
assert refreshed.model == "refreshed-model"
load_snapshot.assert_called_once_with()
def test_selected_preset_generation_does_not_fall_back_to_provider_defaults() -> None:
provider = _provider(temperature=0.1, max_tokens=1024)
resolver = ModelRuntimeResolver(
@@ -198,7 +245,7 @@ def test_selected_preset_generation_does_not_fall_back_to_provider_defaults() ->
selected = resolver.select_preset("creative")
provider.generation = GenerationSettings(temperature=0.9, max_tokens=64)
refreshed = resolver.current(refresh=True)
refreshed = resolver.admit()
assert refreshed is selected
assert refreshed.generation == GenerationSettings(0.7, 4096, None)
+24
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import save_config
@@ -34,6 +36,7 @@ def test_provider_refresh_updates_only_runtime_resolver(tmp_path: Path) -> None:
signature=("new-model",),
),
)
loop.runtime_resolver.invalidate()
runtime = loop.llm_runtime()
@@ -90,6 +93,7 @@ def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
signature=("new-model",),
),
)
loop.runtime_resolver.invalidate()
runtime = loop.llm_runtime()
@@ -99,6 +103,24 @@ def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
assert not hasattr(loop.runner, "provider")
def test_llm_runtime_surfaces_invalidated_config_errors(tmp_path: Path) -> None:
def fail_refresh() -> ProviderSnapshot:
raise ValueError("invalid config")
loop = AgentLoop(
bus=MessageBus(),
provider=_provider("old-model"),
workspace=tmp_path,
model="old-model",
context_window_tokens=1000,
provider_snapshot_loader=fail_refresh,
)
loop.runtime_resolver.invalidate()
with pytest.raises(ValueError, match="invalid config"):
loop.llm_runtime()
def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
base_provider = _provider("base-model")
fast_provider = _provider("fast-model")
@@ -122,6 +144,7 @@ def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path
preset_snapshot_loader=lambda _name: fast_snapshot,
runtime_model_publisher=lambda model, preset: published.append((model, preset)),
)
loop.runtime_resolver.invalidate()
runtime = loop.llm_runtime()
@@ -173,6 +196,7 @@ def test_settings_context_window_refreshes_runtime_state(
loop = AgentLoop.from_config(config, provider_snapshot_loader=loader)
payload = update_agent_settings({"context_window_tokens": ["262144"]})
loop.runtime_resolver.invalidate()
loop.llm_runtime()
assert payload["requires_restart"] is False
+2
View File
@@ -175,6 +175,7 @@ def test_active_model_preset_survives_unchanged_config_refresh(tmp_path) -> None
)
loop.set_model_preset("fast")
loop.runtime_resolver.invalidate()
loop.llm_runtime()
assert loop.model_preset == "fast"
@@ -211,6 +212,7 @@ def test_config_model_refresh_clears_active_model_preset(tmp_path) -> None:
)
loop.set_model_preset("fast")
loop.runtime_resolver.invalidate()
loop.llm_runtime()
assert loop.model_preset is None
+5
View File
@@ -2737,12 +2737,14 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
self.context = _FakeContext()
self.sessions = kwargs["session_manager"]
self.submit_local_trigger_turn = AsyncMock()
self.runtime_resolver = MagicMock()
seen["agent"] = self
def _schedule_background(self, _coro) -> None:
return None
async def run(self) -> None:
self.runtime_resolver.invalidate.assert_called_once_with()
await asyncio.Event().wait()
async def close_mcp(self) -> None:
@@ -2971,6 +2973,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
self.model = "test-model"
self.provider = object()
self.sessions = _FakeSessionManager()
self.runtime_resolver = MagicMock()
def llm_runtime(self) -> None:
return None
@@ -3164,6 +3167,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
self.model = "test-model"
self.provider = object()
self.sessions = _FakeSessionManager()
self.runtime_resolver = MagicMock()
def llm_runtime(self) -> None:
return None
@@ -3262,6 +3266,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
self.model = "test-model"
self.provider = object()
self.sessions = _FakeSessionManager()
self.runtime_resolver = MagicMock()
def llm_runtime(self) -> None:
return None
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from contextlib import suppress
from pathlib import Path
import pytest
from watchfiles import Change
import nanobot.config.watcher as config_watcher
@pytest.mark.asyncio
async def test_watch_config_file_filters_directory_events(
tmp_path: Path,
monkeypatch,
) -> None:
config_path = tmp_path / "config.json"
other_path = tmp_path / "other.json"
seen: dict[str, object] = {}
async def fake_awatch(*paths, **kwargs):
seen["paths"] = paths
seen["recursive"] = kwargs["recursive"]
watch_filter = kwargs["watch_filter"]
assert watch_filter(Change.modified, str(config_path)) is True
assert watch_filter(Change.modified, str(other_path)) is False
yield {(Change.modified, str(config_path))}
monkeypatch.setattr(config_watcher, "awatch", fake_awatch)
changes: list[None] = []
await config_watcher.watch_config_file(config_path, lambda: changes.append(None))
assert seen == {"paths": (tmp_path,), "recursive": False}
assert changes == [None]
@pytest.mark.asyncio
async def test_watch_config_file_observes_atomic_replace(tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("{}", encoding="utf-8")
changed = asyncio.Event()
task = asyncio.create_task(
config_watcher.watch_config_file(config_path, changed.set)
)
try:
for attempt in range(10):
replacement = tmp_path / "config.tmp"
replacement.write_text(f'{{"attempt": {attempt}}}', encoding="utf-8")
replacement.replace(config_path)
try:
await asyncio.wait_for(changed.wait(), timeout=0.2)
break
except TimeoutError:
continue
assert changed.is_set()
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task