refactor: introduce AgentLoop.from_config() to centralize loop assembly

Extract duplicated bus/provider/loop initialization from CLI commands
(serve, _run_gateway, agent) and Nanobot facade into a single
AgentLoop.from_config() classmethod.

- Remove _make_provider() from cli/commands.py and nanobot.py
- Remove inline provider creation in all three CLI entry points
- AgentLoop.from_config() creates MessageBus, calls make_provider(),
  and assembles AgentLoop with all standard config-derived parameters
- Supports **extra overrides for callers that need custom args
  (e.g. cron_service, session_manager, provider_snapshot_loader)
- Update tests to mock make_provider at nanobot.providers.factory
  and add from_config classmethod to _FakeAgentLoop fixtures

This is PR 1/4 of the model-preset feature decomposition.
This commit is contained in:
chengyongru 2026-05-09 14:24:18 +08:00 committed by Xubin Ren
parent 9252f4d826
commit 3202f58c41
5 changed files with 121 additions and 156 deletions

View File

@ -341,6 +341,46 @@ class AgentLoop:
self.commands = CommandRouter() self.commands = CommandRouter()
register_builtin_commands(self.commands) register_builtin_commands(self.commands)
@classmethod
def from_config(
cls,
config: Any,
bus: MessageBus | None = None,
**extra: Any,
) -> AgentLoop:
"""Create an AgentLoop from config with the common parameter set."""
from nanobot.providers.factory import make_provider
if bus is None:
bus = MessageBus()
defaults = config.agents.defaults
provider = make_provider(config)
return cls(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=defaults.model,
max_iterations=defaults.max_tool_iterations,
context_window_tokens=defaults.context_window_tokens,
context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars,
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
web_config=config.tools.web,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
consolidation_ratio=defaults.consolidation_ratio,
max_messages=defaults.max_messages,
tools_config=config.tools,
**extra,
)
def _sync_subagent_runtime_limits(self) -> None: def _sync_subagent_runtime_limits(self) -> None:
"""Keep subagent runtime limits aligned with mutable loop settings.""" """Keep subagent runtime limits aligned with mutable loop settings."""
self.subagents.max_iterations = self.max_iterations self.subagents.max_iterations = self.max_iterations

View File

@ -48,6 +48,7 @@ from rich.table import Table
from rich.text import Text from rich.text import Text
from nanobot import __logo__, __version__ from nanobot import __logo__, __version__
from nanobot.agent.loop import AgentLoop
def _sanitize_surrogates(text: str) -> str: def _sanitize_surrogates(text: str) -> str:
@ -447,20 +448,6 @@ def _onboard_plugins(config_path: Path) -> None:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config.
Routing is driven by ``ProviderSpec.backend`` in the registry.
"""
from nanobot.providers.factory import make_provider
try:
return make_provider(config)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
"""Load config and optionally override the active workspace.""" """Load config and optionally override the active workspace."""
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
@ -539,7 +526,6 @@ def serve(
from loguru import logger from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.api.server import create_app from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -556,32 +542,10 @@ def serve(
timeout = timeout if timeout is not None else api_cfg.timeout timeout = timeout if timeout is not None else api_cfg.timeout
sync_workspace_templates(runtime_config.workspace_path) sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus() bus = MessageBus()
provider = _make_provider(runtime_config)
session_manager = SessionManager(runtime_config.workspace_path) session_manager = SessionManager(runtime_config.workspace_path)
agent_loop = AgentLoop( agent_loop = AgentLoop.from_config(
bus=bus, runtime_config, bus,
provider=provider,
workspace=runtime_config.workspace_path,
model=runtime_config.agents.defaults.model,
max_iterations=runtime_config.agents.defaults.max_tool_iterations,
context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
context_block_limit=runtime_config.agents.defaults.context_block_limit,
max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars,
provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode,
tool_hint_max_length=runtime_config.agents.defaults.tool_hint_max_length,
web_config=runtime_config.tools.web,
exec_config=runtime_config.tools.exec,
restrict_to_workspace=runtime_config.tools.restrict_to_workspace,
session_manager=session_manager, session_manager=session_manager,
mcp_servers=runtime_config.tools.mcp_servers,
channels_config=runtime_config.channels,
timezone=runtime_config.agents.defaults.timezone,
unified_session=runtime_config.agents.defaults.unified_session,
disabled_skills=runtime_config.agents.defaults.disabled_skills,
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
max_messages=runtime_config.agents.defaults.max_messages,
tools_config=runtime_config.tools,
image_generation_provider_configs={ image_generation_provider_configs={
"openrouter": runtime_config.providers.openrouter, "openrouter": runtime_config.providers.openrouter,
"aihubmix": runtime_config.providers.aihubmix, "aihubmix": runtime_config.providers.aihubmix,
@ -653,7 +617,6 @@ def _run_gateway(
open_browser_url: str | None = None, open_browser_url: str | None = None,
) -> None: ) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@ -674,7 +637,6 @@ def _run_gateway(
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
provider = provider_snapshot.provider
session_manager = SessionManager(config.workspace_path) session_manager = SessionManager(config.workspace_path)
# Preserve existing single-workspace installs, but keep custom workspaces clean. # Preserve existing single-workspace installs, but keep custom workspaces clean.
@ -686,31 +648,10 @@ def _run_gateway(
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
# Create agent with cron service # Create agent with cron service
agent = AgentLoop( agent = AgentLoop.from_config(
bus=bus, config, bus,
provider=provider,
workspace=config.workspace_path,
model=provider_snapshot.model,
max_iterations=config.agents.defaults.max_tool_iterations,
context_window_tokens=provider_snapshot.context_window_tokens,
web_config=config.tools.web,
context_block_limit=config.agents.defaults.context_block_limit,
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
provider_retry_mode=config.agents.defaults.provider_retry_mode,
tool_hint_max_length=config.agents.defaults.tool_hint_max_length,
exec_config=config.tools.exec,
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager, session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=config.agents.defaults.timezone,
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
consolidation_ratio=config.agents.defaults.consolidation_ratio,
max_messages=config.agents.defaults.max_messages,
tools_config=config.tools,
image_generation_provider_configs={ image_generation_provider_configs={
"openrouter": config.providers.openrouter, "openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix, "aihubmix": config.providers.aihubmix,
@ -820,7 +761,7 @@ def _run_gateway(
if job.payload.deliver and job.payload.to and response: if job.payload.deliver and job.payload.to and response:
should_notify = await evaluate_response( should_notify = await evaluate_response(
response, reminder_note, provider, agent.model, response, reminder_note, agent.provider, agent.model,
) )
if should_notify: if should_notify:
await _deliver_to_channel( await _deliver_to_channel(
@ -910,7 +851,7 @@ def _run_gateway(
hb_cfg = config.gateway.heartbeat hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService( heartbeat = HeartbeatService(
workspace=config.workspace_path, workspace=config.workspace_path,
provider=provider, provider=agent.provider,
model=agent.model, model=agent.model,
on_execute=on_heartbeat_execute, on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify, on_notify=on_heartbeat_notify,
@ -1063,7 +1004,6 @@ def agent(
"""Interact with the agent directly.""" """Interact with the agent directly."""
from loguru import logger from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
@ -1071,7 +1011,6 @@ def agent(
sync_workspace_templates(config.workspace_path) sync_workspace_templates(config.workspace_path)
bus = MessageBus() bus = MessageBus()
provider = _make_provider(config)
# Preserve existing single-workspace installs, but keep custom workspaces clean. # Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path): if is_default_workspace(config.workspace_path):
@ -1086,30 +1025,9 @@ def agent(
else: else:
logger.disable("nanobot") logger.disable("nanobot")
agent_loop = AgentLoop( agent_loop = AgentLoop.from_config(
bus=bus, config, bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
context_window_tokens=config.agents.defaults.context_window_tokens,
web_config=config.tools.web,
context_block_limit=config.agents.defaults.context_block_limit,
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
provider_retry_mode=config.agents.defaults.provider_retry_mode,
tool_hint_max_length=config.agents.defaults.tool_hint_max_length,
exec_config=config.tools.exec,
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=config.agents.defaults.timezone,
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
consolidation_ratio=config.agents.defaults.consolidation_ratio,
max_messages=config.agents.defaults.max_messages,
tools_config=config.tools,
) )
restart_notice = consume_restart_notice_from_env() restart_notice = consume_restart_notice_from_env()
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):

View File

@ -8,7 +8,6 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
@dataclass(slots=True) @dataclass(slots=True)
@ -62,31 +61,8 @@ class Nanobot:
Path(workspace).expanduser().resolve() Path(workspace).expanduser().resolve()
) )
provider = _make_provider(config) loop = AgentLoop.from_config(
bus = MessageBus() config,
defaults = config.agents.defaults
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=defaults.model,
max_iterations=defaults.max_tool_iterations,
context_window_tokens=defaults.context_window_tokens,
context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars,
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
web_config=config.tools.web,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
consolidation_ratio=defaults.consolidation_ratio,
tools_config=config.tools,
image_generation_provider_configs={ image_generation_provider_configs={
"openrouter": config.providers.openrouter, "openrouter": config.providers.openrouter,
"aihubmix": config.providers.aihubmix, "aihubmix": config.providers.aihubmix,
@ -128,8 +104,3 @@ class Nanobot:
) )
def _make_provider(config: Any) -> Any:
"""Create the LLM provider from config (extracted from CLI)."""
from nanobot.providers.factory import make_provider
return make_provider(config)

View File

@ -9,7 +9,8 @@ import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import _make_provider, app from nanobot.cli.commands import app
from nanobot.providers.factory import make_provider
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.cron.types import CronJob, CronPayload from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.factory import ProviderSnapshot
@ -19,6 +20,13 @@ from nanobot.providers.registry import find_by_name
runner = CliRunner() runner = CliRunner()
def _fake_provider():
"""Return a minimal fake provider that satisfies AgentLoop.__init__."""
p = MagicMock()
p.generation.max_tokens = 4096
return p
class _StopGatewayError(RuntimeError): class _StopGatewayError(RuntimeError):
pass pass
@ -488,7 +496,7 @@ def test_openai_compat_provider_passes_model_through():
def test_make_provider_uses_github_copilot_backend(): def test_make_provider_uses_github_copilot_backend():
from nanobot.cli.commands import _make_provider from nanobot.providers.factory import make_provider
from nanobot.config.schema import Config from nanobot.config.schema import Config
config = Config.model_validate( config = Config.model_validate(
@ -503,7 +511,7 @@ def test_make_provider_uses_github_copilot_backend():
) )
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = _make_provider(config) provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider" assert provider.__class__.__name__ == "GitHubCopilotProvider"
@ -579,7 +587,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
) )
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
_make_provider(config) make_provider(config)
kwargs = mock_async_openai.call_args.kwargs kwargs = mock_async_openai.call_args.kwargs
assert kwargs["api_key"] == "test-key" assert kwargs["api_key"] == "test-key"
@ -597,24 +605,24 @@ def mock_agent_runtime(tmp_path):
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \ patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
patch("nanobot.cli.commands._make_provider", return_value=object()), \ patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
patch("nanobot.bus.queue.MessageBus"), \ patch("nanobot.bus.queue.MessageBus"), \
patch("nanobot.cron.service.CronService"), \ patch("nanobot.cron.service.CronService"), \
patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls: patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
agent_loop = MagicMock() agent_loop = MagicMock()
agent_loop.channels_config = None agent_loop.channels_config = None
agent_loop.process_direct = AsyncMock( agent_loop.process_direct = AsyncMock(
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"), return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
) )
agent_loop.close_mcp = AsyncMock(return_value=None) agent_loop.close_mcp = AsyncMock(return_value=None)
mock_agent_loop_cls.return_value = agent_loop mock_from_config.return_value = agent_loop
yield { yield {
"config": config, "config": config,
"load_config": mock_load_config, "load_config": mock_load_config,
"sync_templates": mock_sync_templates, "sync_templates": mock_sync_templates,
"agent_loop_cls": mock_agent_loop_cls, "from_config": mock_from_config,
"agent_loop": agent_loop, "agent_loop": agent_loop,
"print_response": mock_print_response, "print_response": mock_print_response,
} }
@ -639,9 +647,8 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
assert mock_agent_runtime["sync_templates"].call_args.args == ( assert mock_agent_runtime["sync_templates"].call_args.args == (
mock_agent_runtime["config"].workspace_path, mock_agent_runtime["config"].workspace_path,
) )
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == ( passed_config = mock_agent_runtime["from_config"].call_args.args[0]
mock_agent_runtime["config"].workspace_path assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
)
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once() mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
mock_agent_runtime["print_response"].assert_called_once_with( mock_agent_runtime["print_response"].assert_called_once_with(
"mock-response", render_markdown=True, metadata={}, "mock-response", render_markdown=True, metadata={},
@ -672,11 +679,14 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
) )
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object()) monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
pass pass
@ -686,7 +696,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
return None return None
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@ -707,7 +717,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
class _FakeCron: class _FakeCron:
@ -715,6 +725,9 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
seen["cron_store"] = store_path seen["cron_store"] = store_path
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
pass pass
@ -725,7 +738,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@ -753,7 +766,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
@ -762,6 +775,9 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
seen["cron_store"] = store_path seen["cron_store"] = store_path
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
pass pass
@ -772,7 +788,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke( result = runner.invoke(
@ -806,7 +822,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
@ -815,6 +831,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
seen["cron_store"] = store_path seen["cron_store"] = store_path
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
pass pass
@ -825,7 +844,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
return None return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None "nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
) )
@ -846,7 +865,8 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path passed_config = mock_agent_runtime["from_config"].call_args.args[0]
assert passed_config.workspace_path == workspace_path
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path): def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
@ -863,7 +883,8 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path passed_config = mock_agent_runtime["from_config"].call_args.args[0]
assert passed_config.workspace_path == workspace_path
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path): def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
@ -915,7 +936,7 @@ def _patch_cli_command_runtime(
cron_service=None, cron_service=None,
get_cron_dir=None, get_cron_dir=None,
) -> None: ) -> None:
provider_factory = make_provider or (lambda _config: object()) provider_factory = make_provider or (lambda _config: _fake_provider())
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
@ -928,7 +949,7 @@ def _patch_cli_command_runtime(
sync_templates or (lambda _path: None), sync_templates or (lambda _path: None),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands._make_provider", "nanobot.providers.factory.make_provider",
provider_factory, provider_factory,
) )
monkeypatch.setattr( monkeypatch.setattr(
@ -959,6 +980,9 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
self.on_cleanup: list[object] = [] self.on_cleanup: list[object] = []
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(workspace=config.workspace_path, **extra)
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
seen["workspace"] = kwargs["workspace"] seen["workspace"] = kwargs["workspace"]
@ -985,7 +1009,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: object(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app) monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app) monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
@ -1069,7 +1093,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
config = Config() config = Config()
config.agents.defaults.workspace = str(tmp_path / "config-workspace") config.agents.defaults.workspace = str(tmp_path / "config-workspace")
provider = object() provider = _fake_provider()
bus = MagicMock() bus = MagicMock()
bus.publish_outbound = AsyncMock() bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {} seen: dict[str, object] = {}
@ -1077,7 +1101,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot", "nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config), lambda _config: _test_provider_snapshot(provider, _config),
@ -1115,8 +1139,12 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
seen["cron"] = self seen["cron"] = self
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
self.model = "test-model" self.model = "test-model"
self.provider = object()
self.tools = {} self.tools = {}
async def process_direct(self, *_args, **_kwargs): async def process_direct(self, *_args, **_kwargs):
@ -1152,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
return True return True
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.utils.evaluator.evaluate_response", "nanobot.utils.evaluator.evaluate_response",
@ -1181,7 +1209,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert response == "Time to stretch." assert response == "Time to stretch."
assert seen["response"] == "Time to stretch." assert seen["response"] == "Time to stretch."
assert seen["provider"] is provider assert seen["provider"] is not None
assert seen["model"] == "test-model" assert seen["model"] == "test-model"
assert seen["task_context"] == ( assert seen["task_context"] == (
"The scheduled time has arrived. Deliver this reminder to the user now, " "The scheduled time has arrived. Deliver this reminder to the user now, "
@ -1228,7 +1256,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot", "nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(object(), _config), lambda _config: _test_provider_snapshot(object(), _config),
@ -1246,8 +1274,12 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
seen["cron"] = self seen["cron"] = self
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
self.model = "test-model" self.model = "test-model"
self.provider = object()
self.tools = {} self.tools = {}
async def process_direct(self, *_args, on_progress=None, **_kwargs): async def process_direct(self, *_args, on_progress=None, **_kwargs):
@ -1275,7 +1307,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
return False return False
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.utils.evaluator.evaluate_response", "nanobot.utils.evaluator.evaluate_response",
@ -1478,8 +1510,12 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
return 0 return 0
class _FakeAgentLoop: class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, **_kwargs) -> None: def __init__(self, **_kwargs) -> None:
self.model = "test-model" self.model = "test-model"
self.provider = object()
self.dream = _FakeDream() self.dream = _FakeDream()
self.sessions = _FakeSessionManager() self.sessions = _FakeSessionManager()
@ -1571,7 +1607,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: object(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService) monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)

View File

@ -39,7 +39,7 @@ def test_from_config_default_path():
from nanobot.config.schema import Config from nanobot.config.schema import Config
with patch("nanobot.config.loader.load_config") as mock_load, \ with patch("nanobot.config.loader.load_config") as mock_load, \
patch("nanobot.nanobot._make_provider") as mock_prov: patch("nanobot.providers.factory.make_provider") as mock_prov:
mock_load.return_value = Config() mock_load.return_value = Config()
mock_prov.return_value = MagicMock() mock_prov.return_value = MagicMock()
mock_prov.return_value.get_default_model.return_value = "test" mock_prov.return_value.get_default_model.return_value = "test"
@ -127,7 +127,7 @@ def test_workspace_override(tmp_path):
def test_sdk_make_provider_uses_github_copilot_backend(): def test_sdk_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.nanobot import _make_provider from nanobot.providers.factory import make_provider
config = Config.model_validate( config = Config.model_validate(
{ {
@ -141,7 +141,7 @@ def test_sdk_make_provider_uses_github_copilot_backend():
) )
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = _make_provider(config) provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider" assert provider.__class__.__name__ == "GitHubCopilotProvider"