refactor(heartbeat): migrate heartbeat service to cron-based auto-registration
Remove standalone nanobot/heartbeat/ service and replace it with an auto-registered system cron job on gateway startup. Key behaviors preserved: - HeartbeatConfig (enabled, interval_s, keep_recent_messages) remains in GatewayConfig for backward compatibility. - On startup, if enabled, a system cron job "heartbeat" is registered with schedule derived from interval_s. - HEARTBEAT.md is checked on each tick; empty/template-identical files skip to avoid wasting LLM calls. - Post-run evaluate_response and session history truncation (keep_recent_messages) are retained. - Delivery target selection, deliverable filtering, and preamble guidance are preserved. Files removed: - nanobot/heartbeat/__init__.py - nanobot/heartbeat/service.py - tests/heartbeat/* - tests/agent/test_heartbeat_service.py Templates and docs updated to reflect cron-based usage.
This commit is contained in:
@@ -1,336 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class DummyProvider(LLMProvider):
|
||||
def __init__(self, responses: list[LLMResponse]):
|
||||
super().__init__()
|
||||
self._responses = list(responses)
|
||||
self.calls = 0
|
||||
self.models: list[str | None] = []
|
||||
|
||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||
self.calls += 1
|
||||
self.models.append(kwargs.get("model"))
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return LLMResponse(content="", tool_calls=[])
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_is_idempotent(tmp_path) -> None:
|
||||
provider = DummyProvider([])
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
interval_s=9999,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
await service.start()
|
||||
first_task = service._task
|
||||
await service.start()
|
||||
|
||||
assert service._task is first_task
|
||||
|
||||
service.stop()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_returns_skip_when_no_tool_call(tmp_path) -> None:
|
||||
provider = DummyProvider([LLMResponse(content="no tool call", tool_calls=[])])
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
)
|
||||
|
||||
action, tasks = await service._decide("heartbeat content")
|
||||
assert action == "skip"
|
||||
assert tasks == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_now_executes_when_decision_is_run(tmp_path) -> None:
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check open tasks"},
|
||||
)
|
||||
],
|
||||
)
|
||||
])
|
||||
|
||||
called_with: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
called_with.append(tasks)
|
||||
return "done"
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
result = await service.trigger_now()
|
||||
assert result == "done"
|
||||
assert called_with == ["check open tasks"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None:
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "skip"},
|
||||
)
|
||||
],
|
||||
)
|
||||
])
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return tasks
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
assert await service.trigger_now() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_notifies_when_evaluator_says_yes(tmp_path, monkeypatch) -> None:
|
||||
"""Phase 1 run -> Phase 2 execute -> Phase 3 evaluate=notify -> on_notify called."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check deployments", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check deployments"},
|
||||
)
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
executed: list[str] = []
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
executed.append(tasks)
|
||||
return "deployment failed on staging"
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
async def _eval_notify(*a, **kw):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_notify)
|
||||
|
||||
await service._tick()
|
||||
assert executed == ["check deployments"]
|
||||
assert notified == ["deployment failed on staging"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) -> None:
|
||||
"""Phase 1 run -> Phase 2 execute -> Phase 3 evaluate=silent -> on_notify NOT called."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check status", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check status"},
|
||||
)
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
executed: list[str] = []
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
executed.append(tasks)
|
||||
return "everything is fine, no issues"
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
async def _eval_silent(*a, **kw):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_silent)
|
||||
|
||||
await service._tick()
|
||||
assert executed == ["check status"]
|
||||
assert notified == []
|
||||
|
||||
|
||||
def test_tick_uses_runtime_provider_and_model(tmp_path, monkeypatch) -> None:
|
||||
"""Preset changes must apply to heartbeat decision and post-run evaluation."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check runtime model", encoding="utf-8")
|
||||
|
||||
runtime_provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check runtime model"},
|
||||
)
|
||||
],
|
||||
),
|
||||
])
|
||||
runtime_model = "openai/gpt-4.1"
|
||||
|
||||
executed: list[str] = []
|
||||
evaluated: list[tuple[LLMProvider, str]] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
executed.append(tasks)
|
||||
return "runtime model produced a user-facing update"
|
||||
|
||||
async def _eval_capture(response, tasks, provider, model):
|
||||
evaluated.append((provider, model))
|
||||
return False
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
llm_runtime=lambda: LLMRuntime(runtime_provider, runtime_model),
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_capture)
|
||||
|
||||
asyncio.run(service._tick())
|
||||
|
||||
assert runtime_provider.calls == 1
|
||||
assert runtime_provider.models == [runtime_model]
|
||||
assert executed == ["check runtime model"]
|
||||
assert evaluated == [(runtime_provider, runtime_model)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
|
||||
provider = DummyProvider([
|
||||
LLMResponse(content="429 rate limit", finish_reason="error"),
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check open tasks"},
|
||||
)
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
delays: list[int] = []
|
||||
|
||||
async def _fake_sleep(delay: int) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", _fake_sleep)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
)
|
||||
|
||||
action, tasks = await service._decide("heartbeat content")
|
||||
|
||||
assert action == "run"
|
||||
assert tasks == "check open tasks"
|
||||
assert provider.calls == 2
|
||||
assert delays == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_prompt_includes_current_time(tmp_path) -> None:
|
||||
"""Phase 1 user prompt must contain current time so the LLM can judge task urgency."""
|
||||
|
||||
captured_messages: list[dict] = []
|
||||
|
||||
class CapturingProvider(LLMProvider):
|
||||
async def chat(self, *, messages=None, **kwargs) -> LLMResponse:
|
||||
if messages:
|
||||
captured_messages.extend(messages)
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "skip"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=CapturingProvider(),
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
await service._decide("- [ ] check servers at 10:00 UTC")
|
||||
|
||||
user_msg = captured_messages[1]
|
||||
assert user_msg["role"] == "user"
|
||||
assert "Current Time:" in user_msg["content"]
|
||||
@@ -602,17 +602,17 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
||||
chat_session = loop.sessions.get_or_create("websocket:chat-with-goal")
|
||||
chat_session.metadata[GOAL_STATE_KEY] = {
|
||||
"status": "active",
|
||||
"objective": "This chat goal must not leak into heartbeat.",
|
||||
"objective": "This chat goal must not leak into system.",
|
||||
}
|
||||
loop.sessions.save(chat_session)
|
||||
system_session = loop.sessions.get_or_create("heartbeat")
|
||||
system_session = loop.sessions.get_or_create("system")
|
||||
system_session.metadata = {}
|
||||
loop.sessions.save(system_session)
|
||||
|
||||
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
||||
return_value=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "runtime + heartbeat"},
|
||||
{"role": "user", "content": "runtime + system"},
|
||||
]
|
||||
)
|
||||
loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign]
|
||||
@@ -620,7 +620,7 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
||||
[],
|
||||
[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "runtime + heartbeat"},
|
||||
{"role": "user", "content": "runtime + system"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
"stop",
|
||||
@@ -630,11 +630,11 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
||||
result = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="heartbeat",
|
||||
sender_id="system",
|
||||
chat_id="chat-with-goal",
|
||||
content="heartbeat work",
|
||||
content="system work",
|
||||
),
|
||||
session_key="heartbeat",
|
||||
session_key="system",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
|
||||
@@ -1589,16 +1589,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
def register_system_job(self, _job) -> None:
|
||||
return None
|
||||
|
||||
class _FakeHeartbeatService:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeServer:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -1645,7 +1635,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
||||
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Tests for heartbeat context bridge — injecting delivered messages into channel session."""
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
class TestHeartbeatContextBridge:
|
||||
"""Verify that on_heartbeat_notify injects the assistant message into the
|
||||
channel session so user replies have conversational context."""
|
||||
|
||||
def test_notify_injects_into_channel_session(self, tmp_path):
|
||||
"""After notify, the target channel session should contain the
|
||||
heartbeat response as an assistant turn."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Simulate: session exists with one user message
|
||||
target_session = session_mgr.get_or_create(target_key)
|
||||
target_session.add_message("user", "hello earlier")
|
||||
session_mgr.save(target_session)
|
||||
|
||||
# Simulate what on_heartbeat_notify does
|
||||
target_session = session_mgr.get_or_create(target_key)
|
||||
target_session.add_message(
|
||||
"assistant",
|
||||
"3 new emails — invoice, meeting, proposal.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(target_session)
|
||||
|
||||
# Reload and verify
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
messages = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in messages]
|
||||
assert roles == ["user", "assistant"]
|
||||
assert "3 new emails" in messages[-1]["content"]
|
||||
|
||||
def test_reply_after_injection_has_context(self, tmp_path):
|
||||
"""Simulates the full flow: prior conversation exists, heartbeat
|
||||
injects, then user replies. The session should have the heartbeat
|
||||
message visible in get_history so the model sees the context."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Pre-existing conversation (user has chatted before)
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "Hey")
|
||||
session.add_message("assistant", "Hi there!")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Step 1: heartbeat injects assistant message
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"If you want, I can mark that email as read.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(session)
|
||||
|
||||
# Step 2: user replies "Sure"
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "Sure")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Verify: get_history includes the heartbeat injection
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in history]
|
||||
assert roles == ["user", "assistant", "assistant", "user"]
|
||||
assert "mark that email" in history[2]["content"]
|
||||
assert history[3]["content"] == "Sure"
|
||||
|
||||
def test_injection_does_not_duplicate_on_existing_history(self, tmp_path):
|
||||
"""If the channel session already has messages, the injection
|
||||
appends cleanly without corruption."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Pre-existing conversation
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "What time is it?")
|
||||
session.add_message("assistant", "It's 2pm.")
|
||||
session.add_message("user", "Thanks")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Heartbeat injects
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"You have a meeting in 30 minutes.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(session)
|
||||
|
||||
# Verify
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in history]
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
assert "meeting in 30 minutes" in history[-1]["content"]
|
||||
|
||||
def test_reply_after_injection_to_empty_session_keeps_context(self, tmp_path):
|
||||
"""A user replying to the first delivered message still sees that context."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:99999"
|
||||
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"Weather alert: sandstorm expected at 4pm.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session.add_message("user", "Sure")
|
||||
session_mgr.save(session)
|
||||
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "assistant"
|
||||
assert "sandstorm" in history[0]["content"]
|
||||
assert history[1] == {"role": "user", "content": "Sure"}
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Tests for HeartbeatService._is_deliverable and _tick suppression."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_deliverable unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsDeliverable:
|
||||
"""Verify the pre-evaluator deliverability filter."""
|
||||
|
||||
def test_normal_report_is_deliverable(self):
|
||||
assert HeartbeatService._is_deliverable(
|
||||
"2 new emails — invoice from Zain, meeting rescheduled to 3pm."
|
||||
)
|
||||
|
||||
def test_short_dismissal_is_deliverable(self):
|
||||
assert HeartbeatService._is_deliverable("All clear.")
|
||||
|
||||
def test_finalization_fallback_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
)
|
||||
|
||||
def test_leaked_heartbeat_md_reference_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Yes — HEARTBEAT.md has active tasks listed. They are: "
|
||||
"Check Gmail for important messages, Check Calendar."
|
||||
)
|
||||
|
||||
def test_leaked_awareness_md_reference_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I reviewed AWARENESS.md and found no new signals."
|
||||
)
|
||||
|
||||
def test_leaked_judgment_call_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Best judgment call: stay quiet."
|
||||
)
|
||||
|
||||
def test_leaked_decision_logic_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Strict HEARTBEAT interpretation. Decision logic says SHORT UPDATE."
|
||||
)
|
||||
|
||||
def test_leaked_valid_options_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"The valid options are FULL REPORT, SHORT UPDATE, or SILENT."
|
||||
)
|
||||
|
||||
def test_leaked_my_instructions_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"My instructions say to check Gmail and Calendar."
|
||||
)
|
||||
|
||||
def test_leaked_supposed_to_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I am supposed to scan for urgent emails."
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"HEARTBEAT.MD has tasks listed."
|
||||
)
|
||||
|
||||
def test_empty_string_is_deliverable(self):
|
||||
"""Empty string won't reach _is_deliverable in practice (caught earlier),
|
||||
but should not crash."""
|
||||
assert HeartbeatService._is_deliverable("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _tick integration: non-deliverable responses never reach evaluator/notify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_suppresses_finalization_fallback(tmp_path, monkeypatch) -> None:
|
||||
"""Finalization fallback should be caught before the evaluator runs."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check inbox"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
evaluator_called = False
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return (
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
)
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
nonlocal evaluator_called
|
||||
evaluator_called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == [], "Finalization fallback should not reach the user"
|
||||
assert not evaluator_called, "Evaluator should not be called for non-deliverable responses"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_suppresses_leaked_reasoning(tmp_path, monkeypatch) -> None:
|
||||
"""Leaked internal reasoning should be caught before the evaluator runs."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check status", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check status"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return "HEARTBEAT.md has active tasks listed. They are: Check Gmail."
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == [], "Leaked reasoning should not reach the user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_delivers_normal_report(tmp_path, monkeypatch) -> None:
|
||||
"""Normal reports should pass through deliverability and evaluator."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check inbox"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return "3 new emails — client proposal from Zain, invoice, meeting reminder."
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == ["3 new emails — client proposal from Zain, invoice, meeting reminder."]
|
||||
Reference in New Issue
Block a user