diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ca26cbf37..29eb19c31 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,12 +1,11 @@ """CLI commands for nanobot.""" import asyncio -from contextlib import contextmanager, nullcontext - import os import select import signal import sys +from contextlib import nullcontext from pathlib import Path from typing import Any @@ -73,6 +72,7 @@ def _flush_pending_tty_input() -> None: try: import termios + termios.tcflush(fd, termios.TCIFLUSH) return except Exception: @@ -95,6 +95,7 @@ def _restore_terminal() -> None: return try: import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) except Exception: pass @@ -107,6 +108,7 @@ def _init_prompt_session() -> None: # Save terminal state so we can restore it on exit try: import termios + _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) except Exception: pass @@ -119,7 +121,7 @@ def _init_prompt_session() -> None: _PROMPT_SESSION = PromptSession( history=FileHistory(str(history_file)), enable_open_in_editor=False, - multiline=False, # Enter submits (single line mode) + multiline=False, # Enter submits (single line mode) ) @@ -231,7 +233,6 @@ async def _read_interactive_input_async() -> str: raise KeyboardInterrupt from exc - def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -281,8 +282,12 @@ def onboard( config = _apply_workspace_override(load_config(config_path)) else: console.print(f"[yellow]Config already exists at {config_path}[/yellow]") - console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") - console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") + console.print( + " [bold]y[/bold] = overwrite with defaults (existing values will be lost)" + ) + console.print( + " [bold]N[/bold] = refresh config, keeping existing values and adding new fields" + ) if typer.confirm("Overwrite?"): config = _apply_workspace_override(Config()) save_config(config, config_path) @@ -290,7 +295,9 @@ def onboard( else: config = _apply_workspace_override(load_config(config_path)) save_config(config, config_path) - console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") + console.print( + f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)" + ) else: config = _apply_workspace_override(Config()) # In wizard mode, don't save yet - the wizard will handle saving if should_save=True @@ -340,7 +347,9 @@ def onboard( console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]") - console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") + console.print( + "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]" + ) def _merge_missing_defaults(existing: Any, defaults: Any) -> Any: @@ -413,9 +422,11 @@ def _make_provider(config: Config): # --- instantiation by backend --- if backend == "openai_codex": from nanobot.providers.openai_codex_provider import OpenAICodexProvider + provider = OpenAICodexProvider(default_model=model) elif backend == "azure_openai": from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + provider = AzureOpenAIProvider( api_key=p.api_key, api_base=p.api_base, @@ -426,6 +437,7 @@ def _make_provider(config: Config): provider = GitHubCopilotProvider(default_model=model) elif backend == "anthropic": from nanobot.providers.anthropic_provider import AnthropicProvider + provider = AnthropicProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), @@ -434,6 +446,7 @@ def _make_provider(config: Config): ) else: from nanobot.providers.openai_compat_provider import OpenAICompatProvider + provider = OpenAICompatProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), @@ -478,6 +491,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None def _warn_deprecated_config_keys(config_path: Path | None) -> None: """Hint users to remove obsolete keys from their config file.""" import json + from nanobot.config.loader import get_config_path path = config_path or get_config_path() @@ -501,6 +515,7 @@ def _migrate_cron_store(config: "Config") -> None: if legacy_path.is_file() and not new_path.exists(): new_path.parent.mkdir(parents=True, exist_ok=True) import shutil + shutil.move(str(legacy_path), str(new_path)) @@ -614,6 +629,7 @@ def gateway( if verbose: import logging + logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) @@ -699,7 +715,7 @@ def gateway( if job.payload.deliver and job.payload.to and response: should_notify = await evaluate_response( - response, job.payload.message, provider, agent.model, + response, reminder_note, provider, agent.model, ) if should_notify: from nanobot.bus.events import OutboundMessage @@ -709,6 +725,7 @@ def gateway( content=response, )) return response + cron.on_job = on_cron_job # Create channel manager @@ -812,6 +829,7 @@ def gateway( console.print("\nShutting down...") except Exception: import traceback + console.print("\n[red]Error: Gateway crashed unexpectedly[/red]") console.print(traceback.format_exc()) finally: @@ -824,8 +842,6 @@ def gateway( asyncio.run(run()) - - # ============================================================================ # Agent Commands # ============================================================================ @@ -1300,6 +1316,7 @@ def _register_login(name: str): def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn + return decorator @@ -1330,6 +1347,7 @@ def provider_login( def _login_openai_codex() -> None: try: from oauth_cli_kit import get_token, login_oauth_interactive + token = None try: token = get_token() diff --git a/nanobot/templates/agent/evaluator.md b/nanobot/templates/agent/evaluator.md index 305e4f8d0..51cf7a4e4 100644 --- a/nanobot/templates/agent/evaluator.md +++ b/nanobot/templates/agent/evaluator.md @@ -1,7 +1,9 @@ {% if part == 'system' %} You are a notification gate for a background agent. You will be given the original task and the agent's response. Call the evaluate_notification tool to decide whether the user should be notified. -Notify when the response contains actionable information, errors, completed deliverables, or anything the user explicitly asked to be reminded about. +Notify when the response contains actionable information, errors, completed deliverables, scheduled reminder/timer completions, or anything the user explicitly asked to be reminded about. + +A user-scheduled reminder should usually notify even when the response is brief or mostly repeats the original reminder. Suppress when the response is a routine status check with nothing new, a confirmation that everything is normal, or essentially empty. {% elif part == 'user' %} diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 4a1a00632..3a1e7145a 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1,5 +1,7 @@ +import asyncio import json import re +import shutil from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -9,6 +11,7 @@ from typer.testing import CliRunner from nanobot.bus.events import OutboundMessage from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config +from nanobot.cron.types import CronJob, CronPayload from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.registry import find_by_name @@ -19,11 +22,6 @@ class _StopGatewayError(RuntimeError): pass -import shutil - -import pytest - - @pytest.fixture def mock_paths(): """Mock config/workspace paths for test isolation.""" @@ -31,7 +29,6 @@ def mock_paths(): patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.load_config") as mock_lc, \ patch("nanobot.cli.commands.get_workspace_path") as mock_ws: - base_dir = Path("./test_onboard_data") if base_dir.exists(): shutil.rmtree(base_dir) @@ -432,7 +429,6 @@ def mock_agent_runtime(tmp_path): patch("nanobot.bus.queue.MessageBus"), \ patch("nanobot.cron.service.CronService"), \ patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls: - agent_loop = MagicMock() agent_loop.channels_config = None agent_loop.process_direct = AsyncMock( @@ -657,7 +653,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron( monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.agent.loop.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)]) @@ -870,6 +868,115 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" +def test_gateway_cron_evaluator_receives_scheduled_reminder_context( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + provider = object() + bus = MagicMock() + bus.publish_outbound = AsyncMock() + seen: dict[str, object] = {} + + 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.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) + monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + + class _FakeCron: + def __init__(self, _store_path: Path) -> None: + self.on_job = None + seen["cron"] = self + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + self.model = "test-model" + self.tools = {} + + async def process_direct(self, *_args, **_kwargs): + return OutboundMessage( + channel="telegram", + chat_id="user-1", + content="Time to stretch.", + ) + + async def close_mcp(self) -> None: + return None + + async def run(self) -> None: + return None + + def stop(self) -> None: + return None + + class _StopAfterCronSetup: + def __init__(self, *_args, **_kwargs) -> None: + raise _StopGatewayError("stop") + + async def _capture_evaluate_response( + response: str, + task_context: str, + provider_arg: object, + model: str, + ) -> bool: + seen["response"] = response + seen["task_context"] = task_context + seen["provider"] = provider_arg + seen["model"] = model + return True + + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) + monkeypatch.setattr( + "nanobot.utils.evaluator.evaluate_response", + _capture_evaluate_response, + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGatewayError) + cron = seen["cron"] + assert isinstance(cron, _FakeCron) + assert cron.on_job is not None + + job = CronJob( + id="cron-1", + name="stretch", + payload=CronPayload( + message="Remind me to stretch.", + deliver=True, + channel="telegram", + to="user-1", + ), + ) + + response = asyncio.run(cron.on_job(job)) + + assert response == "Time to stretch." + assert seen["response"] == "Time to stretch." + assert seen["provider"] is provider + assert seen["model"] == "test-model" + assert seen["task_context"] == ( + "[Scheduled Task] Timer finished.\n\n" + "Task 'stretch' has been triggered.\n" + "Scheduled instruction: Remind me to stretch." + ) + bus.publish_outbound.assert_awaited_once_with( + OutboundMessage( + channel="telegram", + chat_id="user-1", + content="Time to stretch.", + ) + ) + + def test_gateway_workspace_override_does_not_migrate_legacy_cron( monkeypatch, tmp_path: Path ) -> None: