Merge PR #2449: fix: cron reminder notifications being suppressed

fix: cron reminder notifications being suppressed
This commit is contained in:
Xubin Ren 2026-04-06 14:33:20 +08:00 committed by GitHub
commit b92d54140d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 20 deletions

View File

@ -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()

View File

@ -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' %}

View File

@ -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: