diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 38e45a5f6..4dc157295 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -533,9 +533,15 @@ class AgentLoop: self.subagents.max_iterations = self.max_iterations def invalidate_runtime_config(self) -> None: - """Invalidate runtime config and notify clients to refresh its catalog.""" + """Invalidate runtime config for lazy refresh at the next admission.""" self.runtime_resolver.invalidate() - self._publish_runtime_selection(self.runtime_resolver.runtime) + + def refresh_runtime_config(self) -> LLMRuntime: + """Refresh runtime config now and publish the canonical selection.""" + self.runtime_resolver.invalidate() + runtime = self.runtime_resolver.admit() + self._publish_runtime_selection(runtime) + return runtime def runtime_for_session( self, @@ -1787,7 +1793,7 @@ class AgentLoop: session.provider_state = None self.sessions.save(session) ctx.input_persisted_early = True - ctx.delivery.record_runtime(runtime) + await ctx.delivery.runtime_admitted(runtime) ctx.request_context = self._request_context_for_turn(ctx) if ctx.kind is TurnKind.USER: diff --git a/nanobot/agent/turn_delivery.py b/nanobot/agent/turn_delivery.py index 5b3746b2d..f828016aa 100644 --- a/nanobot/agent/turn_delivery.py +++ b/nanobot/agent/turn_delivery.py @@ -189,7 +189,15 @@ class TurnDelivery: started_at=started_at, ) - def record_runtime(self, runtime: LLMRuntime) -> None: + async def runtime_admitted(self, runtime: LLMRuntime) -> None: + """Record the immutable runtime and expose it at the lifecycle seam.""" + if self.route.publish_lifecycle: + await self.runtime_event_publisher.turn_runtime_admitted( + self.delivery_message, + self.session_key, + runtime, + ) + return self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime) def record_latency(self, latency_ms: int | None) -> None: diff --git a/nanobot/bus/outbound_events.py b/nanobot/bus/outbound_events.py index f750b2c74..55a514867 100644 --- a/nanobot/bus/outbound_events.py +++ b/nanobot/bus/outbound_events.py @@ -84,9 +84,10 @@ class RuntimeModelUpdatedEvent(OutboundEvent): @dataclass(frozen=True) class TurnModelUpdatedEvent(OutboundEvent): - """The fallback model currently handling one chat turn.""" + """The canonical preset and concrete model handling one chat turn.""" model: str + model_preset: str | None = None def outbound_message_for_event( diff --git a/nanobot/bus/runtime_events.py b/nanobot/bus/runtime_events.py index 30be6f402..c6d2778cc 100644 --- a/nanobot/bus/runtime_events.py +++ b/nanobot/bus/runtime_events.py @@ -40,6 +40,14 @@ class SessionTurnStarted: context: RuntimeEventContext +@dataclass(frozen=True) +class TurnRuntimeAdmitted: + """The immutable model runtime selected for one admitted turn.""" + + context: RuntimeEventContext + runtime: LLMRuntime + + @dataclass(frozen=True) class TurnRunStatusChanged: """Visible run status changed for a turn.""" @@ -85,6 +93,7 @@ class RuntimeModelChanged: RuntimeEvent = ( SessionTurnStarted + | TurnRuntimeAdmitted | SessionTurnPersisted | TurnRunStatusChanged | TurnCompleted @@ -93,6 +102,7 @@ RuntimeEvent = ( ) RuntimeEventType = ( type[SessionTurnStarted] + | type[TurnRuntimeAdmitted] | type[SessionTurnPersisted] | type[TurnRunStatusChanged] | type[TurnCompleted] @@ -204,6 +214,26 @@ class RuntimeEventPublisher: ) ) + async def turn_runtime_admitted( + self, + msg: InboundMessage, + session_key: str, + runtime: LLMRuntime, + ) -> None: + """Record and publish the runtime selected for one turn.""" + self.record_turn_runtime(session_key, runtime) + await self.bus.publish( + TurnRuntimeAdmitted( + context=self._context( + channel=msg.channel, + chat_id=msg.chat_id, + session_key=session_key, + metadata=msg.metadata, + ), + runtime=runtime, + ) + ) + async def run_status_changed( self, msg: InboundMessage, diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 68c0f1e26..a15c4ab1a 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -1407,6 +1407,7 @@ class WebSocketChannel(BaseChannel): await self.send_turn_model_updated( msg.chat_id, model_name=event.model, + model_preset=event.model_preset, ) return if isinstance(event, GoalStateSyncEvent): @@ -1774,6 +1775,7 @@ class WebSocketChannel(BaseChannel): chat_id: str, *, model_name: Any, + model_preset: Any = None, ) -> None: """Notify one chat's subscribers which model is handling its current request.""" conns = list(self._subs.get(chat_id, ())) @@ -1788,6 +1790,8 @@ class WebSocketChannel(BaseChannel): "chat_id": chat_id, "model_name": model_name.strip(), } + if isinstance(model_preset, str) and model_preset.strip(): + body["model_preset"] = model_preset.strip() raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_model_updated ") diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index bb967a0b0..c9833d0d1 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1642,7 +1642,10 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None: channel="websocket", chat_id="chat-1", content="", - event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"), + event=TurnModelUpdatedEvent( + model="deepseek/deepseek-chat", + model_preset="Deep Research", + ), ) ) @@ -1651,6 +1654,7 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None: "event": "turn_model_updated", "chat_id": "chat-1", "model_name": "deepseek/deepseek-chat", + "model_preset": "Deep Research", } chat_two.send.assert_not_awaited() diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index d233bcf12..5cc577867 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -658,7 +658,7 @@ def _run_gateway( return agent.model.strip() or None def _webui_refresh_runtime_config() -> None: - agent.invalidate_runtime_config() + agent.refresh_runtime_config() def _webui_skill_state_action(disabled_skills: set[str]) -> None: config.agents.defaults.disabled_skills = sorted(disabled_skills) diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 2313c4ffa..9c17d256e 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -33,6 +33,7 @@ from nanobot.bus.runtime_events import ( SessionTurnStarted, TurnCompleted, TurnRunStatusChanged, + TurnRuntimeAdmitted, ) from nanobot.providers.base import LLMProvider from nanobot.providers.fallback_provider import FallbackModelObserver @@ -459,7 +460,14 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve outbound_message_for_event( channel=context.channel, chat_id=chat_id, - event=TurnModelUpdatedEvent(model=model), + event=TurnModelUpdatedEvent( + model=model, + model_preset=( + context.runtime.model_preset + if context.runtime is not None + else None + ), + ), metadata=context.metadata, ) ) @@ -486,6 +494,10 @@ class WebuiTurnCoordinator: self._handle_run_status_changed, TurnRunStatusChanged, ), + runtime_events.subscribe( + self._handle_turn_runtime_admitted, + TurnRuntimeAdmitted, + ), runtime_events.subscribe( self._handle_turn_completed_event, TurnCompleted, @@ -537,6 +549,21 @@ class WebuiTurnCoordinator: started_at=event.started_at, ) + async def _handle_turn_runtime_admitted(self, event: TurnRuntimeAdmitted) -> None: + if not self._is_websocket_event(event.context): + return + await self.bus.publish_outbound( + outbound_message_for_event( + channel=event.context.channel, + chat_id=event.context.chat_id, + event=TurnModelUpdatedEvent( + model=event.runtime.model, + model_preset=event.runtime.model_preset, + ), + metadata=event.context.metadata, + ) + ) + async def _handle_turn_completed_event(self, event: TurnCompleted) -> None: if not self._is_websocket_event(event.context): return diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index 975aa39c8..a5cb4f9b2 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -1646,6 +1646,11 @@ class ModelSettingsHandler: self.settings = settings self.logger = logger + def _refresh_runtime_config(self) -> None: + """Make a successful model-settings mutation visible to live clients now.""" + if self.settings.refresh_runtime_config is not None: + self.settings.refresh_runtime_config() + async def handle( self, action: str, @@ -1655,6 +1660,7 @@ class ModelSettingsHandler: try: if action == "agent-update": payload = self.settings.mutate(operations.update_agent, request.query) + self._refresh_runtime_config() return SettingsRouteResult.success( payload, decorate_restart=True, @@ -1667,8 +1673,7 @@ class ModelSettingsHandler: request.query, rename_model_preset=self.settings.rename_model_preset, ) - if self.settings.refresh_runtime_config is not None: - self.settings.refresh_runtime_config() + self._refresh_runtime_config() return SettingsRouteResult.success(payload, decorate_restart=True) mutation = { @@ -1680,6 +1685,7 @@ class ModelSettingsHandler: }.get(action) if mutation is not None: payload = self.settings.mutate(mutation, request.query) + self._refresh_runtime_config() return SettingsRouteResult.success(payload, decorate_restart=True) if action == "provider-update": @@ -1690,6 +1696,7 @@ class ModelSettingsHandler: payload, image_restart_cleared = await operations.apply_image_runtime_change( payload ) + self._refresh_runtime_config() return SettingsRouteResult.success( payload, decorate_restart=True, diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py index f09b1286d..3ed91e3d0 100644 --- a/tests/agent/test_runtime_refresh.py +++ b/tests/agent/test_runtime_refresh.py @@ -226,7 +226,7 @@ def test_named_default_refresh_is_used_by_sessions_without_override(tmp_path: Pa @pytest.mark.asyncio -async def test_config_invalidation_notifies_clients_before_session_runtime_refresh( +async def test_config_invalidation_defers_canonical_notification_until_default_refresh( tmp_path: Path, ) -> None: provider = _provider("model-a") @@ -266,12 +266,59 @@ async def test_config_invalidation_notifies_clients_before_session_runtime_refre runtime = loop.runtime_for_session(session) await asyncio.sleep(0) - assert [(event.model, event.model_preset) for event in published] == [ - ("model-a", "fast"), - ] + assert published == [] assert runtime.model == "model-b" assert loop.model_presets["fast"].model == "model-b" + assert loop.llm_runtime().model == "model-b" + await asyncio.sleep(0) + + assert [(event.model, event.model_preset) for event in published] == [ + ("model-b", "fast"), + ] + + +@pytest.mark.asyncio +async def test_config_refresh_publishes_renamed_canonical_preset(tmp_path: Path) -> None: + provider = _provider("model-a") + catalog = {"fast": ModelPresetConfig(model="model-a")} + default_name = "fast" + published: list[RuntimeModelChanged] = [] + + def load_preset(name: str) -> ProviderSnapshot: + return ProviderSnapshot( + provider=provider, + model=catalog[name].model, + context_window_tokens=16_000, + signature=(name, catalog[name].model), + model_preset=name, + ) + + loop = AgentLoop( + bus=MessageBus(), + provider=provider, + workspace=tmp_path, + model="model-a", + context_window_tokens=16_000, + provider_signature=("fast", "model-a"), + provider_snapshot_loader=lambda: load_preset(default_name), + model_presets=catalog, + preset_catalog_loader=lambda: catalog, + model_preset="fast", + preset_snapshot_loader=load_preset, + ) + loop.runtime_events.subscribe(published.append, RuntimeModelChanged) + catalog["Codex"] = catalog.pop("fast") + default_name = "Codex" + + runtime = loop.refresh_runtime_config() + await asyncio.sleep(0) + + assert (runtime.model, runtime.model_preset) == ("model-a", "Codex") + assert [(event.model, event.model_preset) for event in published] == [ + ("model-a", "Codex"), + ] + def test_next_turn_captures_generation_changed_after_previous_admission( tmp_path: Path, diff --git a/tests/bus/test_runtime_events.py b/tests/bus/test_runtime_events.py index 3ef96914a..deedccc80 100644 --- a/tests/bus/test_runtime_events.py +++ b/tests/bus/test_runtime_events.py @@ -10,6 +10,7 @@ from nanobot.bus.runtime_events import ( SessionTurnStarted, TurnCompleted, TurnRunStatusChanged, + TurnRuntimeAdmitted, ) @@ -123,6 +124,37 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N assert second.runtime is None +@pytest.mark.asyncio +async def test_runtime_event_publisher_exposes_admitted_runtime() -> None: + bus = RuntimeEventBus() + seen: list[object] = [] + publisher = RuntimeEventPublisher(bus) + msg = InboundMessage( + channel="websocket", + sender_id="user", + chat_id="chat-a", + content="hello", + ) + runtime = object() + bus.subscribe(seen.append) + + await publisher.turn_runtime_admitted(msg, "websocket:chat-a", runtime) # type: ignore[arg-type] + await publisher.turn_completed( + channel="websocket", + chat_id="chat-a", + session_key="websocket:chat-a", + metadata=None, + ) + + admitted = seen[0] + completed = seen[1] + assert isinstance(admitted, TurnRuntimeAdmitted) + assert admitted.runtime is runtime + assert admitted.context.chat_id == "chat-a" + assert isinstance(completed, TurnCompleted) + assert completed.runtime is runtime + + @pytest.mark.asyncio async def test_runtime_event_publisher_emits_persisted_turn_attributes() -> None: bus = RuntimeEventBus() diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py index 7a113eae9..5274f3572 100644 --- a/tests/utils/test_webui_turn_helpers.py +++ b/tests/utils/test_webui_turn_helpers.py @@ -7,7 +7,11 @@ import pytest from nanobot.agent.tools.context import RequestContext, request_context from nanobot.bus.events import InboundMessage from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent +from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventContext, TurnRuntimeAdmitted +from nanobot.providers.base import GenerationSettings from nanobot.session import webui_turns as wth +from nanobot.session.manager import SessionManager +from nanobot.utils.llm_runtime import LLMRuntime from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY @@ -144,10 +148,18 @@ async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None: bus.publish_outbound = AsyncMock() observer = wth.build_webui_fallback_model_observer(bus) + runtime = LLMRuntime( + provider=MagicMock(), + model="openai/gpt-4.1", + generation=GenerationSettings(), + context_window_tokens=16_000, + model_preset="Deep Research", + ) with request_context( RequestContext( channel="websocket", chat_id="chat-model", + runtime=runtime, metadata={"webui": True}, ) ): @@ -159,6 +171,46 @@ async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None: assert outbound.metadata == {"webui": True} assert isinstance(outbound.event, TurnModelUpdatedEvent) assert outbound.event.model == "deepseek/deepseek-chat" + assert outbound.event.model_preset == "Deep Research" + + +@pytest.mark.asyncio +async def test_admitted_runtime_publishes_chat_scoped_model_and_preset(tmp_path) -> None: + bus = MagicMock() + bus.publish_outbound = AsyncMock() + runtime_events = RuntimeEventBus() + coordinator = wth.WebuiTurnCoordinator( + bus=bus, + sessions=SessionManager(tmp_path), + schedule_background=lambda coro: coro.close(), + ) + coordinator.subscribe(runtime_events) + runtime = LLMRuntime( + provider=MagicMock(), + model="openai-codex/gpt-5.6", + generation=GenerationSettings(), + context_window_tokens=262_144, + model_preset="Codex", + ) + + await runtime_events.publish( + TurnRuntimeAdmitted( + context=RuntimeEventContext( + channel="websocket", + chat_id="chat-model", + session_key="websocket:chat-model", + metadata={"webui": True}, + ), + runtime=runtime, + ) + ) + + outbound = bus.publish_outbound.await_args.args[0] + assert outbound.channel == "websocket" + assert outbound.chat_id == "chat-model" + assert isinstance(outbound.event, TurnModelUpdatedEvent) + assert outbound.event.model == "openai-codex/gpt-5.6" + assert outbound.event.model_preset == "Codex" @pytest.mark.asyncio diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index e7dfaaa71..84d8f3ed5 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -307,6 +307,18 @@ async def test_oauth_completion_reads_websocket_payload( @pytest.mark.parametrize( ("route_path", "function_name", "payload", "expected_query"), [ + ( + "/api/settings/update", + "update_agent_settings", + {"model_preset": "Codex"}, + {"model_preset": ["Codex"]}, + ), + ( + "/api/settings/model-configurations/create", + "create_model_configuration", + {"name": "Codex", "model": "openai-codex/gpt-5.6"}, + {"name": ["Codex"], "model": ["openai-codex/gpt-5.6"]}, + ), ( "/api/settings/model-configurations/delete", "delete_model_configuration", @@ -325,10 +337,22 @@ async def test_oauth_completion_reads_websocket_payload( {"order": ["backup"]}, {"order": ['["backup"]']}, ), + ( + "/api/settings/provider/create", + "create_provider_settings", + {"name": "team", "api_base": "https://llm.example/v1"}, + {"name": ["team"], "api_base": ["https://llm.example/v1"]}, + ), + ( + "/api/settings/provider/update", + "update_provider_settings", + {"provider": "team", "api_base": "https://llm.example/v2"}, + {"provider": ["team"], "api_base": ["https://llm.example/v2"]}, + ), ], ) @pytest.mark.asyncio -async def test_model_preset_mutation_routes( +async def test_runtime_config_mutation_routes_refresh_live_runtime( monkeypatch, route_path: str, function_name: str, @@ -336,6 +360,7 @@ async def test_model_preset_mutation_routes( expected_query: dict[str, list[str]], ) -> None: captured: dict[str, object] = {} + refresh_runtime_config = MagicMock() def mutate(query, *, config_path=None): captured["query"] = query @@ -344,12 +369,15 @@ async def test_model_preset_mutation_routes( monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate) request = _mutation_request(route_path, payload) - response = await _router().dispatch(None, request, route_path) + response = await _router( + refresh_runtime_config=refresh_runtime_config, + ).dispatch(None, request, route_path) assert response is not None assert response.status_code == 200 assert json.loads(response.body)["routed"] == function_name assert captured["query"] == expected_query + refresh_runtime_config.assert_called_once_with() @pytest.mark.asyncio diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 56d18627c..ed6270672 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1271,6 +1271,7 @@ export type InboundEvent = event: "turn_model_updated"; chat_id: string; model_name: string; + model_preset?: string | null; } | ({ event: "turn_end"; diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 48113a995..7fd3de3e2 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -1636,12 +1636,14 @@ describe("NanobotClient", () => { event: "turn_model_updated", chat_id: "chat-a", model_name: "deepseek/deepseek-chat", + model_preset: "Deep Research", }); expect(chatHandler).toHaveBeenCalledWith({ event: "turn_model_updated", chat_id: "chat-a", model_name: "deepseek/deepseek-chat", + model_preset: "Deep Research", }); });