mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867bbdeb66 | ||
|
|
5f5521d2e6 | ||
|
|
3ecd042ef0 | ||
|
|
f57a670ef8 | ||
|
|
b5db9fcd52 | ||
|
|
ca17292768 |
+34
-180
@@ -878,13 +878,12 @@ def _run_gateway(
|
|||||||
health_server_enabled: bool = True,
|
health_server_enabled: bool = True,
|
||||||
) -> 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.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
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
from nanobot.cron.executor import CronJobExecutor
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
|
||||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -976,174 +975,44 @@ def _run_gateway(
|
|||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.set_send_callback(_deliver_to_channel)
|
message_tool.set_send_callback(_deliver_to_channel)
|
||||||
|
|
||||||
# Set cron callback (needs agent)
|
hb_cfg = config.gateway.heartbeat
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
|
||||||
"""Execute a cron job through the agent."""
|
|
||||||
async def _silent(*_args, **_kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Dream is an internal job — run directly, not through the agent loop.
|
def _get_channel(channel_name: str) -> Any | None:
|
||||||
if job.name == "dream":
|
try:
|
||||||
from nanobot.agent.memory import MemoryStore
|
return channels.channels.get(channel_name)
|
||||||
|
except NameError:
|
||||||
dream_session_key = MemoryStore.dream_session_key
|
|
||||||
build_dream_commit_message = MemoryStore.build_dream_commit_message
|
|
||||||
prune_dream_sessions = MemoryStore.prune_dream_sessions
|
|
||||||
|
|
||||||
store = agent.context.memory
|
|
||||||
resp = None
|
|
||||||
try:
|
|
||||||
result = store.build_dream_prompt()
|
|
||||||
if result is None:
|
|
||||||
logger.info("Dream: nothing to process")
|
|
||||||
return None
|
|
||||||
prompt, last_cursor = result
|
|
||||||
key = dream_session_key()
|
|
||||||
resp = await agent.process_direct(
|
|
||||||
prompt,
|
|
||||||
session_key=key,
|
|
||||||
ephemeral=True,
|
|
||||||
tools=store.build_dream_tools(),
|
|
||||||
on_progress=_silent,
|
|
||||||
)
|
|
||||||
if MemoryStore.dream_run_completed(resp):
|
|
||||||
store.set_last_dream_cursor(last_cursor)
|
|
||||||
logger.info("Dream cron job completed, cursor advanced to {}", last_cursor)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Dream cron job did not complete; cursor remains at {}",
|
|
||||||
store.get_last_dream_cursor(),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Dream cron job failed")
|
|
||||||
finally:
|
|
||||||
if store.git.is_initialized():
|
|
||||||
msg = build_dream_commit_message(
|
|
||||||
"dream: periodic memory consolidation", resp,
|
|
||||||
)
|
|
||||||
sha = store.git.auto_commit(msg)
|
|
||||||
if sha:
|
|
||||||
logger.info("Dream commit: {}", sha)
|
|
||||||
store.compact_history()
|
|
||||||
prune_dream_sessions(agent.sessions.sessions_dir)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
if job.name == "heartbeat":
|
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||||
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
|
|
||||||
try:
|
|
||||||
content = heartbeat_file.read_text(encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
logger.debug("Heartbeat: HEARTBEAT.md missing")
|
|
||||||
return None
|
|
||||||
if not _heartbeat_has_active_tasks(content):
|
|
||||||
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
|
|
||||||
return None
|
|
||||||
|
|
||||||
channel, chat_id = _pick_heartbeat_target()
|
|
||||||
if channel == "cli":
|
|
||||||
return None
|
|
||||||
|
|
||||||
prompt = (
|
|
||||||
_HEARTBEAT_PREAMBLE
|
|
||||||
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Internal check: funnel all output through the post-run gate so the
|
|
||||||
# turn can't deliver directly via the message tool and skip it.
|
|
||||||
suppress_token = None
|
|
||||||
if isinstance(message_tool, MessageTool):
|
|
||||||
suppress_token = message_tool.set_suppress_delivery(True)
|
|
||||||
try:
|
|
||||||
resp = await agent.process_direct(
|
|
||||||
prompt,
|
|
||||||
session_key="heartbeat",
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
on_progress=_silent,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if isinstance(message_tool, MessageTool) and suppress_token is not None:
|
|
||||||
message_tool.reset_suppress_delivery(suppress_token)
|
|
||||||
response = resp.content if resp else ""
|
|
||||||
|
|
||||||
# Keep a small tail of heartbeat history so the loop stays bounded.
|
|
||||||
session = agent.sessions.get_or_create("heartbeat")
|
|
||||||
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
|
||||||
agent.sessions.save(session)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Fail closed: stay silent on evaluator failure instead of notifying.
|
|
||||||
should_notify = await evaluate_response(
|
|
||||||
response, prompt, agent.provider, agent.model,
|
|
||||||
default_notify=False,
|
|
||||||
)
|
|
||||||
if should_notify:
|
|
||||||
logger.info("Heartbeat: completed, delivering response")
|
|
||||||
await _deliver_to_channel(
|
|
||||||
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
|
||||||
record=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
|
||||||
return response
|
|
||||||
|
|
||||||
reminder_note = (
|
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
|
||||||
"as a brief and natural message in their language. Speak directly to them — "
|
|
||||||
"do not narrate progress, summarize, include user IDs, or add status reports "
|
|
||||||
"like 'Done' or 'Reminded'.\n\n"
|
|
||||||
f"Reminder: {job.payload.message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cron_tool = agent.tools.get("cron")
|
|
||||||
cron_token = None
|
|
||||||
if isinstance(cron_tool, CronTool):
|
|
||||||
cron_token = cron_tool.set_cron_context(True)
|
|
||||||
|
|
||||||
message_record_token = None
|
|
||||||
if isinstance(message_tool, MessageTool):
|
|
||||||
message_record_token = message_tool.set_record_channel_delivery(True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await agent.process_direct(
|
enabled = set(channels.enabled_channels)
|
||||||
reminder_note,
|
except NameError:
|
||||||
session_key=f"cron:{job.id}",
|
return "cli", "direct"
|
||||||
channel=job.payload.channel or "cli",
|
for item in session_manager.list_sessions():
|
||||||
chat_id=job.payload.to or "direct",
|
key = item.get("key") or ""
|
||||||
on_progress=_silent,
|
if ":" not in key:
|
||||||
)
|
continue
|
||||||
finally:
|
channel, chat_id = key.split(":", 1)
|
||||||
if isinstance(cron_tool, CronTool) and cron_token is not None:
|
if channel in {"cli", "system"}:
|
||||||
cron_tool.reset_cron_context(cron_token)
|
continue
|
||||||
if isinstance(message_tool, MessageTool) and message_record_token is not None:
|
if channel in enabled and chat_id:
|
||||||
message_tool.reset_record_channel_delivery(message_record_token)
|
return channel, chat_id
|
||||||
|
return "cli", "direct"
|
||||||
|
|
||||||
response = resp.content if resp else ""
|
cron_executor = CronJobExecutor(
|
||||||
|
agent=agent,
|
||||||
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
bus=bus,
|
||||||
return response
|
deliver_to_channel=_deliver_to_channel,
|
||||||
|
get_channel=_get_channel,
|
||||||
if job.payload.deliver and job.payload.to and response:
|
evaluate_response=evaluate_response,
|
||||||
should_notify = await evaluate_response(
|
heartbeat_workspace=config.workspace_path,
|
||||||
response, reminder_note, agent.provider, agent.model,
|
heartbeat_preamble=_HEARTBEAT_PREAMBLE,
|
||||||
)
|
heartbeat_has_active_tasks=_heartbeat_has_active_tasks,
|
||||||
if should_notify:
|
pick_heartbeat_target=_pick_heartbeat_target,
|
||||||
await _deliver_to_channel(
|
heartbeat_keep_recent_messages=hb_cfg.keep_recent_messages,
|
||||||
OutboundMessage(
|
)
|
||||||
channel=job.payload.channel or "cli",
|
cron.on_job = cron_executor.run
|
||||||
chat_id=job.payload.to,
|
|
||||||
content=response,
|
|
||||||
metadata=dict(job.payload.channel_meta),
|
|
||||||
),
|
|
||||||
record=True,
|
|
||||||
session_key=job.payload.session_key,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
cron.on_job = on_cron_job
|
|
||||||
|
|
||||||
def _webui_runtime_model_name() -> str | None:
|
def _webui_runtime_model_name() -> str | None:
|
||||||
model = getattr(agent, "model", None)
|
model = getattr(agent, "model", None)
|
||||||
@@ -1164,20 +1033,6 @@ def _run_gateway(
|
|||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
|
||||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
|
||||||
enabled = set(channels.enabled_channels)
|
|
||||||
for item in session_manager.list_sessions():
|
|
||||||
key = item.get("key") or ""
|
|
||||||
if ":" not in key:
|
|
||||||
continue
|
|
||||||
channel, chat_id = key.split(":", 1)
|
|
||||||
if channel in {"cli", "system"}:
|
|
||||||
continue
|
|
||||||
if channel in enabled and chat_id:
|
|
||||||
return channel, chat_id
|
|
||||||
return "cli", "direct"
|
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
else:
|
else:
|
||||||
@@ -1187,7 +1042,6 @@ def _run_gateway(
|
|||||||
if cron_status["jobs"] > 0:
|
if cron_status["jobs"] > 0:
|
||||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||||
|
|
||||||
hb_cfg = config.gateway.heartbeat
|
|
||||||
if hb_cfg.enabled:
|
if hb_cfg.enabled:
|
||||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""Cron job execution for the gateway runtime."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import nanobot.utils.evaluator as evaluator
|
||||||
|
from nanobot.agent.tools.cron import CronTool
|
||||||
|
from nanobot.agent.tools.message import MessageTool
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.cron.types import CronJob
|
||||||
|
|
||||||
|
|
||||||
|
class DeliverToChannel(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
msg: OutboundMessage,
|
||||||
|
*,
|
||||||
|
record: bool = False,
|
||||||
|
session_key: str | None = None,
|
||||||
|
) -> Awaitable[None]: ...
|
||||||
|
|
||||||
|
|
||||||
|
ChannelLookup = Callable[[str], Any | None]
|
||||||
|
EvaluateResponse = Callable[..., Awaitable[bool]]
|
||||||
|
HeartbeatTaskDetector = Callable[[str], bool]
|
||||||
|
HeartbeatTargetPicker = Callable[[], tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class _CronStreamBuffer:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
chat_id: str,
|
||||||
|
channel_meta: dict[str, Any],
|
||||||
|
base_id: str,
|
||||||
|
) -> None:
|
||||||
|
self.channel = channel
|
||||||
|
self.chat_id = chat_id
|
||||||
|
self.channel_meta = channel_meta
|
||||||
|
self.base_id = base_id
|
||||||
|
self.segment = 0
|
||||||
|
self.events: list[OutboundMessage] = []
|
||||||
|
self.has_delta = False
|
||||||
|
|
||||||
|
def _stream_id(self) -> str:
|
||||||
|
return f"{self.base_id}:{self.segment}"
|
||||||
|
|
||||||
|
async def on_stream(self, delta: str) -> None:
|
||||||
|
meta = dict(self.channel_meta)
|
||||||
|
meta["_stream_delta"] = True
|
||||||
|
meta["_stream_id"] = self._stream_id()
|
||||||
|
self.events.append(OutboundMessage(
|
||||||
|
channel=self.channel,
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
content=delta,
|
||||||
|
metadata=meta,
|
||||||
|
))
|
||||||
|
if delta:
|
||||||
|
self.has_delta = True
|
||||||
|
|
||||||
|
async def on_stream_end(self, *, resuming: bool = False) -> None:
|
||||||
|
meta = dict(self.channel_meta)
|
||||||
|
meta["_stream_end"] = True
|
||||||
|
meta["_resuming"] = resuming
|
||||||
|
meta["_stream_id"] = self._stream_id()
|
||||||
|
self.events.append(OutboundMessage(
|
||||||
|
channel=self.channel,
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
content="",
|
||||||
|
metadata=meta,
|
||||||
|
))
|
||||||
|
self.segment += 1
|
||||||
|
|
||||||
|
async def publish(self, bus: MessageBus) -> None:
|
||||||
|
for event in self.events:
|
||||||
|
await bus.publish_outbound(event)
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobExecutor:
|
||||||
|
"""Runs scheduled cron jobs through the agent and optional channel delivery."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent: Any,
|
||||||
|
bus: MessageBus,
|
||||||
|
deliver_to_channel: DeliverToChannel,
|
||||||
|
get_channel: ChannelLookup | None = None,
|
||||||
|
evaluate_response: EvaluateResponse | None = None,
|
||||||
|
heartbeat_workspace: Path | None = None,
|
||||||
|
heartbeat_preamble: str = "",
|
||||||
|
heartbeat_has_active_tasks: HeartbeatTaskDetector | None = None,
|
||||||
|
pick_heartbeat_target: HeartbeatTargetPicker | None = None,
|
||||||
|
heartbeat_keep_recent_messages: int = 8,
|
||||||
|
) -> None:
|
||||||
|
self.agent = agent
|
||||||
|
self.bus = bus
|
||||||
|
self.deliver_to_channel = deliver_to_channel
|
||||||
|
self.get_channel = get_channel or (lambda _channel: None)
|
||||||
|
self.evaluate_response = evaluate_response or evaluator.evaluate_response
|
||||||
|
self.heartbeat_workspace = heartbeat_workspace
|
||||||
|
self.heartbeat_preamble = heartbeat_preamble
|
||||||
|
self.heartbeat_has_active_tasks = heartbeat_has_active_tasks
|
||||||
|
self.pick_heartbeat_target = pick_heartbeat_target
|
||||||
|
self.heartbeat_keep_recent_messages = heartbeat_keep_recent_messages
|
||||||
|
|
||||||
|
async def run(self, job: CronJob) -> str | None:
|
||||||
|
if job.name == "dream":
|
||||||
|
return await self._run_dream()
|
||||||
|
if job.name == "heartbeat":
|
||||||
|
return await self._run_heartbeat()
|
||||||
|
|
||||||
|
return await self._run_agent_turn(job)
|
||||||
|
|
||||||
|
async def _run_dream(self) -> None:
|
||||||
|
from nanobot.agent.memory import MemoryStore
|
||||||
|
|
||||||
|
dream_session_key = MemoryStore.dream_session_key
|
||||||
|
build_dream_commit_message = MemoryStore.build_dream_commit_message
|
||||||
|
prune_dream_sessions = MemoryStore.prune_dream_sessions
|
||||||
|
|
||||||
|
store = self.agent.context.memory
|
||||||
|
resp = None
|
||||||
|
try:
|
||||||
|
result = store.build_dream_prompt()
|
||||||
|
if result is None:
|
||||||
|
logger.info("Dream: nothing to process")
|
||||||
|
return None
|
||||||
|
prompt, last_cursor = result
|
||||||
|
resp = await self.agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key=dream_session_key(),
|
||||||
|
ephemeral=True,
|
||||||
|
tools=store.build_dream_tools(),
|
||||||
|
on_progress=self._silent,
|
||||||
|
)
|
||||||
|
if MemoryStore.dream_run_completed(resp):
|
||||||
|
store.set_last_dream_cursor(last_cursor)
|
||||||
|
logger.info("Dream cron job completed, cursor advanced to {}", last_cursor)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Dream cron job did not complete; cursor remains at {}",
|
||||||
|
store.get_last_dream_cursor(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Dream cron job failed")
|
||||||
|
finally:
|
||||||
|
if store.git.is_initialized():
|
||||||
|
msg = build_dream_commit_message(
|
||||||
|
"dream: periodic memory consolidation", resp,
|
||||||
|
)
|
||||||
|
sha = store.git.auto_commit(msg)
|
||||||
|
if sha:
|
||||||
|
logger.info("Dream commit: {}", sha)
|
||||||
|
store.compact_history()
|
||||||
|
prune_dream_sessions(self.agent.sessions.sessions_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _run_heartbeat(self) -> str | None:
|
||||||
|
if (
|
||||||
|
self.heartbeat_workspace is None
|
||||||
|
or self.heartbeat_has_active_tasks is None
|
||||||
|
or self.pick_heartbeat_target is None
|
||||||
|
):
|
||||||
|
logger.warning("Heartbeat cron job skipped: executor is not configured for heartbeat")
|
||||||
|
return None
|
||||||
|
|
||||||
|
heartbeat_file = self.heartbeat_workspace / "HEARTBEAT.md"
|
||||||
|
try:
|
||||||
|
content = heartbeat_file.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md missing")
|
||||||
|
return None
|
||||||
|
if not self.heartbeat_has_active_tasks(content):
|
||||||
|
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel, chat_id = self.pick_heartbeat_target()
|
||||||
|
if channel == "cli":
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
self.heartbeat_preamble
|
||||||
|
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
message_tool = self._tool("message")
|
||||||
|
suppress_token = None
|
||||||
|
if isinstance(message_tool, MessageTool):
|
||||||
|
suppress_token = message_tool.set_suppress_delivery(True)
|
||||||
|
try:
|
||||||
|
resp = await self.agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key="heartbeat",
|
||||||
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
on_progress=self._silent,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if isinstance(message_tool, MessageTool) and suppress_token is not None:
|
||||||
|
message_tool.reset_suppress_delivery(suppress_token)
|
||||||
|
response = resp.content if resp else ""
|
||||||
|
|
||||||
|
session = self.agent.sessions.get_or_create("heartbeat")
|
||||||
|
session.retain_recent_legal_suffix(self.heartbeat_keep_recent_messages)
|
||||||
|
self.agent.sessions.save(session)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
should_notify = await self.evaluate_response(
|
||||||
|
response, prompt, self.agent.provider, self.agent.model,
|
||||||
|
default_notify=False,
|
||||||
|
)
|
||||||
|
if should_notify:
|
||||||
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
await self.deliver_to_channel(
|
||||||
|
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||||
|
record=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _run_agent_turn(self, job: CronJob) -> str | None:
|
||||||
|
reminder_note = self._reminder_note(job)
|
||||||
|
cron_tool = self._tool("cron")
|
||||||
|
cron_token = None
|
||||||
|
if isinstance(cron_tool, CronTool):
|
||||||
|
cron_token = cron_tool.set_cron_context(True)
|
||||||
|
|
||||||
|
message_tool = self._tool("message")
|
||||||
|
message_record_token = None
|
||||||
|
if isinstance(message_tool, MessageTool):
|
||||||
|
message_record_token = message_tool.set_record_channel_delivery(True)
|
||||||
|
|
||||||
|
channel_name = job.payload.channel or "cli"
|
||||||
|
chat_id = job.payload.to or "direct"
|
||||||
|
stream = self._stream_buffer(job, channel_name=channel_name, chat_id=chat_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.agent.process_direct(
|
||||||
|
reminder_note,
|
||||||
|
session_key=f"cron:{job.id}",
|
||||||
|
channel=channel_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
on_progress=self._silent,
|
||||||
|
on_stream=stream.on_stream if stream else None,
|
||||||
|
on_stream_end=stream.on_stream_end if stream else None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if isinstance(cron_tool, CronTool) and cron_token is not None:
|
||||||
|
cron_tool.reset_cron_context(cron_token)
|
||||||
|
if isinstance(message_tool, MessageTool) and message_record_token is not None:
|
||||||
|
message_tool.reset_record_channel_delivery(message_record_token)
|
||||||
|
|
||||||
|
response = resp.content if resp else ""
|
||||||
|
|
||||||
|
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||||
|
await self._publish_turn_end_if_needed(job, channel_name=channel_name, chat_id=chat_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
delivered = False
|
||||||
|
if job.payload.deliver and job.payload.to and response:
|
||||||
|
should_notify = await self.evaluate_response(
|
||||||
|
response, reminder_note, self.agent.provider, self.agent.model,
|
||||||
|
)
|
||||||
|
if should_notify:
|
||||||
|
meta = dict(job.payload.channel_meta)
|
||||||
|
if stream and stream.has_delta:
|
||||||
|
await stream.publish(self.bus)
|
||||||
|
meta["_streamed"] = True
|
||||||
|
await self.deliver_to_channel(
|
||||||
|
OutboundMessage(
|
||||||
|
channel=channel_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=response,
|
||||||
|
metadata=meta,
|
||||||
|
),
|
||||||
|
record=True,
|
||||||
|
session_key=job.payload.session_key,
|
||||||
|
)
|
||||||
|
delivered = True
|
||||||
|
|
||||||
|
if delivered:
|
||||||
|
await self._publish_turn_end_if_needed(job, channel_name=channel_name, chat_id=chat_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _tool(self, name: str) -> Any | None:
|
||||||
|
tools = getattr(self.agent, "tools", {})
|
||||||
|
if hasattr(tools, "get"):
|
||||||
|
return tools.get(name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _stream_buffer(
|
||||||
|
self,
|
||||||
|
job: CronJob,
|
||||||
|
*,
|
||||||
|
channel_name: str,
|
||||||
|
chat_id: str,
|
||||||
|
) -> _CronStreamBuffer | None:
|
||||||
|
target_channel = self.get_channel(channel_name)
|
||||||
|
wants_stream = bool(
|
||||||
|
job.payload.deliver
|
||||||
|
and job.payload.to
|
||||||
|
and target_channel is not None
|
||||||
|
and target_channel.supports_streaming
|
||||||
|
)
|
||||||
|
if not wants_stream:
|
||||||
|
return None
|
||||||
|
return _CronStreamBuffer(
|
||||||
|
channel=channel_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
channel_meta=job.payload.channel_meta,
|
||||||
|
base_id=f"cron:{job.id}:{time.time_ns()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _publish_turn_end_if_needed(
|
||||||
|
self,
|
||||||
|
job: CronJob,
|
||||||
|
*,
|
||||||
|
channel_name: str,
|
||||||
|
chat_id: str,
|
||||||
|
) -> None:
|
||||||
|
if channel_name != "websocket" or not job.payload.to:
|
||||||
|
return
|
||||||
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=channel_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content="",
|
||||||
|
metadata={**job.payload.channel_meta, "_turn_end": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _silent(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reminder_note(job: CronJob) -> str:
|
||||||
|
return (
|
||||||
|
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||||
|
"as a brief and natural message in their language. Speak directly to them — "
|
||||||
|
"do not narrate progress, summarize, include user IDs, or add status reports "
|
||||||
|
"like 'Done' or 'Reminded'.\n\n"
|
||||||
|
f"Reminder: {job.payload.message}"
|
||||||
|
)
|
||||||
@@ -1421,6 +1421,262 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
bus.publish_outbound.assert_not_awaited()
|
bus.publish_outbound.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_cron_job_streams_when_channel_supports_it(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Cron jobs on streaming channels must emit deltas with stream_id and turn_end."""
|
||||||
|
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")
|
||||||
|
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.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
|
lambda _config: _test_provider_snapshot(object(), _config),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.factory.load_provider_snapshot",
|
||||||
|
lambda _config_path=None: _test_provider_snapshot(object(), config),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||||
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||||
|
|
||||||
|
async def _always_notify(*_args, **_kwargs) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
class _FakeStreamingChannel:
|
||||||
|
supports_streaming = True
|
||||||
|
|
||||||
|
class _FakeChannelManager:
|
||||||
|
def __init__(self, *_args, **_kwargs) -> None:
|
||||||
|
self.channels = {"websocket": _FakeStreamingChannel()}
|
||||||
|
self.enabled_channels = ["websocket"]
|
||||||
|
|
||||||
|
async def start_all(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop_all(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _FakeCron:
|
||||||
|
def __init__(self, _store_path: Path) -> None:
|
||||||
|
self.on_job = None
|
||||||
|
seen["cron"] = self
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"enabled": True, "jobs": 0, "next_wake_at_ms": None}
|
||||||
|
|
||||||
|
def register_system_job(self, job):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _FakeAgentLoop:
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config, bus=None, **extra):
|
||||||
|
return cls(**extra)
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
|
self.tools = {}
|
||||||
|
self.dream = MagicMock()
|
||||||
|
self.sessions = MagicMock()
|
||||||
|
|
||||||
|
async def process_direct(self, *_args, on_stream=None, on_stream_end=None, **_kwargs):
|
||||||
|
seen["on_stream"] = on_stream
|
||||||
|
seen["on_stream_end"] = on_stream_end
|
||||||
|
if on_stream:
|
||||||
|
await on_stream("Hello")
|
||||||
|
await on_stream(" world")
|
||||||
|
if on_stream_end:
|
||||||
|
await on_stream_end(resuming=False)
|
||||||
|
return OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="user-1",
|
||||||
|
content="Hello world",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.commands.evaluate_response",
|
||||||
|
_always_notify,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
cron = seen["cron"]
|
||||||
|
job = CronJob(
|
||||||
|
id="cron-stream-test",
|
||||||
|
name="test-stream",
|
||||||
|
payload=CronPayload(
|
||||||
|
message="Say hello.",
|
||||||
|
deliver=True,
|
||||||
|
channel="websocket",
|
||||||
|
to="user-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = asyncio.run(cron.on_job(job))
|
||||||
|
|
||||||
|
assert response == "Hello world"
|
||||||
|
assert seen["on_stream"] is not None
|
||||||
|
assert seen["on_stream_end"] is not None
|
||||||
|
|
||||||
|
calls = bus.publish_outbound.await_args_list
|
||||||
|
# First two calls are streaming deltas
|
||||||
|
assert calls[0].args[0].metadata.get("_stream_delta") is True
|
||||||
|
assert calls[0].args[0].metadata.get("_stream_id") is not None
|
||||||
|
assert calls[0].args[0].content == "Hello"
|
||||||
|
assert calls[1].args[0].metadata.get("_stream_delta") is True
|
||||||
|
assert calls[1].args[0].metadata.get("_stream_id") == calls[0].args[0].metadata["_stream_id"]
|
||||||
|
assert calls[1].args[0].content == " world"
|
||||||
|
# Third call is stream_end
|
||||||
|
assert calls[2].args[0].metadata.get("_stream_end") is True
|
||||||
|
assert calls[2].args[0].metadata.get("_stream_id") == calls[0].args[0].metadata["_stream_id"]
|
||||||
|
# Fourth call is the final message with _streamed marker
|
||||||
|
assert calls[3].args[0].metadata.get("_streamed") is True
|
||||||
|
assert calls[3].args[0].content == "Hello world"
|
||||||
|
# Fifth call is turn_end
|
||||||
|
assert calls[4].args[0].metadata.get("_turn_end") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_cron_job_streaming_respects_disabled_delivery(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Streaming cron output must not reach the channel when delivery is disabled."""
|
||||||
|
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")
|
||||||
|
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.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
|
lambda _config: _test_provider_snapshot(object(), _config),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.factory.load_provider_snapshot",
|
||||||
|
lambda _config_path=None: _test_provider_snapshot(object(), config),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||||
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||||
|
|
||||||
|
class _FakeStreamingChannel:
|
||||||
|
supports_streaming = True
|
||||||
|
|
||||||
|
class _FakeChannelManager:
|
||||||
|
def __init__(self, *_args, **_kwargs) -> None:
|
||||||
|
self.channels = {"websocket": _FakeStreamingChannel()}
|
||||||
|
self.enabled_channels = ["websocket"]
|
||||||
|
|
||||||
|
async def start_all(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop_all(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _FakeCron:
|
||||||
|
def __init__(self, _store_path: Path) -> None:
|
||||||
|
self.on_job = None
|
||||||
|
seen["cron"] = self
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {"enabled": True, "jobs": 0, "next_wake_at_ms": None}
|
||||||
|
|
||||||
|
def register_system_job(self, job):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _FakeAgentLoop:
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config, bus=None, **extra):
|
||||||
|
return cls(**extra)
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
|
self.tools = {}
|
||||||
|
self.dream = MagicMock()
|
||||||
|
self.sessions = MagicMock()
|
||||||
|
|
||||||
|
async def process_direct(self, *_args, on_stream=None, on_stream_end=None, **_kwargs):
|
||||||
|
seen["on_stream"] = on_stream
|
||||||
|
seen["on_stream_end"] = on_stream_end
|
||||||
|
if on_stream:
|
||||||
|
await on_stream("This should not leak")
|
||||||
|
if on_stream_end:
|
||||||
|
await on_stream_end(resuming=False)
|
||||||
|
return OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="user-1",
|
||||||
|
content="This should not leak",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
cron = seen["cron"]
|
||||||
|
job = CronJob(
|
||||||
|
id="cron-stream-rejected-test",
|
||||||
|
name="test-stream-rejected",
|
||||||
|
payload=CronPayload(
|
||||||
|
message="Say something optional.",
|
||||||
|
deliver=False,
|
||||||
|
channel="websocket",
|
||||||
|
to="user-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = asyncio.run(cron.on_job(job))
|
||||||
|
|
||||||
|
assert response == "This should not leak"
|
||||||
|
assert seen["on_stream"] is None
|
||||||
|
assert seen["on_stream_end"] is None
|
||||||
|
bus.publish_outbound.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user