diff --git a/docs/memory.md b/docs/memory.md index acb67b46..c57593b9 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -197,13 +197,13 @@ Dream is configured under `agents.defaults.dream`: |-------|---------| | `intervalH` | How often Dream runs, in hours | | `cron` | Cron expression override (takes precedence over `intervalH`) | -| `modelOverride` | Optional Dream-specific model override *(pending implementation)* | +| `modelOverride` | Optional model preset name used for Dream | In practical terms: - `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule. - `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`). -- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent. +- `modelOverride` selects a named entry from `model_presets` for Dream. It accepts preset names only; raw model identifiers are not supported. If omitted, Dream uses the main agent's selected runtime. ## In Practice diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5d81bee0..b3a3dad9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -220,6 +220,12 @@ class AgentLoop: self._publish_runtime_selection(runtime) return runtime + def dream_runtime(self) -> LLMRuntime | None: + """Resolve the optional preset used for Dream without changing defaults.""" + if not self.dream_model_preset: + return None + return self.runtime_resolver.resolve_preset(self.dream_model_preset) + _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" _PENDING_USER_TURN_KEY = "pending_user_turn" @@ -257,6 +263,7 @@ class AgentLoop: model_presets: dict[str, ModelPresetConfig] | None = None, preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None, model_preset: str | None = None, + dream_model_preset: str | None = None, preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, runtime_events: RuntimeEventBus | None = None, turn_delivery_factory: TurnDeliveryFactory | None = None, @@ -311,6 +318,7 @@ class AgentLoop: provider_snapshot_loader=provider_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader, ) + self.dream_model_preset = dream_model_preset self.context_block_limit = context_block_limit self.max_tool_result_chars = ( max_tool_result_chars @@ -474,6 +482,7 @@ class AgentLoop: tools_config=config.tools, model_presets=preset_helpers.configured_model_presets(config), model_preset=defaults.model_preset, + dream_model_preset=defaults.dream.model_override, restart_mode=config.gateway.restart_mode, provider_snapshot_loader=provider_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader, @@ -1573,6 +1582,12 @@ class AgentLoop: if runtime is None: runtime = self.runtime_for_session(ctx.session) ctx.runtime = runtime + if ctx.session_key.startswith("dream:"): + logger.info( + "Dream run using model={} (preset={})", + runtime.model, + runtime.model_preset or "default", + ) if ctx.on_runtime_admitted is not None: await ctx.on_runtime_admitted(runtime) replay_max_messages = replay_max_messages_for_context( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 67df115e..3cc71c8f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1820,12 +1820,17 @@ def _run_gateway( return None prompt, last_cursor = result key = dream_session_key() + resolve_dream_runtime = getattr(agent, "dream_runtime", None) + dream_runtime = ( + resolve_dream_runtime() if callable(resolve_dream_runtime) else None + ) resp = await agent.process_direct( prompt, session_key=key, ephemeral=True, tools=store.build_dream_tools(), on_progress=progress, + runtime=dream_runtime, ) # The real file delta grounds the audit record; clean completion # decides whether this history batch has finished processing. diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 3b3befa8..3ba68783 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -427,12 +427,15 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: return prompt, last_cursor = result key = dream_session_key() + resolve_dream_runtime = getattr(loop, "dream_runtime", None) + dream_runtime = resolve_dream_runtime() if callable(resolve_dream_runtime) else None resp = await loop.process_direct( prompt, session_key=key, ephemeral=True, tools=store.build_dream_tools(), on_progress=progress, + runtime=dream_runtime, ) elapsed = time.monotonic() - t0 # The real file delta grounds the audit record; clean completion diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index abfc29c9..467d23a7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -63,7 +63,7 @@ class DreamConfig(Base): model_override: str | None = Field( default=None, validation_alias=AliasChoices("modelOverride", "model", "model_override"), - ) # Override model for Dream sessions (pending implementation) + ) # Model preset name for Dream sessions def build_schedule(self, timezone: str) -> CronSchedule: """Build the runtime schedule, preferring the legacy cron override if present.""" @@ -436,6 +436,9 @@ class Config(BaseSettings): name = self.agents.defaults.model_preset if name and name != "default" and name not in self.model_presets: raise ValueError(f"model_preset {name!r} not found in model_presets") + dream_name = self.agents.defaults.dream.model_override + if dream_name and dream_name != "default" and dream_name not in self.model_presets: + raise ValueError(f"Dream model preset {dream_name!r} not found in model_presets") for fallback in self.agents.defaults.fallback_models: if isinstance(fallback, str) and fallback not in self.model_presets: raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets") diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index f34ad17a..bf9eb403 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -3,6 +3,7 @@ import pytest from nanobot.agent.memory import MemoryStore +from nanobot.config.schema import ModelPresetConfig from nanobot.providers.base import LLMResponse from nanobot.security.workspace_access import ( bind_workspace_scope, @@ -385,6 +386,21 @@ class TestEphemeralDirect: return loop, store + def test_dream_runtime_uses_preset_without_changing_default(self, _make_loop): + loop, _ = _make_loop + loop.runtime_resolver._model_presets = { + "dream": ModelPresetConfig(model="dream-model"), + } + loop.dream_model_preset = "dream" + + runtime = loop.dream_runtime() + + assert runtime is not None + assert runtime.model == "dream-model" + assert runtime.model_preset == "dream" + assert loop.model == "test-model" + assert loop.model_preset is None + async def test_ephemeral_skips_raw_archive(self, tmp_path, _make_loop): """When ephemeral=True, raw_archive must not be called.""" from unittest.mock import patch diff --git a/tests/command/test_builtin_dream.py b/tests/command/test_builtin_dream.py index b90df244..b7c9aa5d 100644 --- a/tests/command/test_builtin_dream.py +++ b/tests/command/test_builtin_dream.py @@ -171,11 +171,13 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None: sessions_dir = tmp_path / "sessions" sessions_dir.mkdir() + dream_runtime = object() loop = SimpleNamespace( bus=bus, context=SimpleNamespace(memory=store, timezone="UTC"), sessions=SimpleNamespace(sessions_dir=sessions_dir), process_direct=process_direct, + dream_runtime=lambda: dream_runtime, ) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop) @@ -184,6 +186,7 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None: assert len(calls) == 1 assert callable(calls[0][1]["on_progress"]) + assert calls[0][1]["runtime"] is dream_runtime def _build_runnable_dream( diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index 0805a456..7a6bab0d 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -267,6 +267,24 @@ def test_validator_rejects_unknown_preset() -> None: }) +def test_validator_accepts_dream_model_preset() -> None: + config = Config.model_validate({ + "modelPresets": { + "dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"}, + }, + "agents": {"defaults": {"dream": {"modelOverride": "dream"}}}, + }) + + assert config.agents.defaults.dream.model_override == "dream" + + +def test_validator_rejects_unknown_dream_model_preset() -> None: + with pytest.raises(ValueError, match="Dream model preset 'unknown' not found"): + Config.model_validate({ + "agents": {"defaults": {"dream": {"modelOverride": "unknown"}}}, + }) + + def test_model_preset_accepts_explicit_default_name() -> None: config = Config.model_validate({ "agents": {