Compare commits

..
Author SHA1 Message Date
nanobotandClaude Sonnet 4.5 deb1fcd68b MessageTool writes to session; remove max_messages limit
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Build Nanobot OAuth / build (pull_request) Successful in 23m57s
- MessageTool now writes sent messages to session history via SessionManager
- Agent loop wires SessionManager into MessageTool constructor
- Session.get_history() returns full history (removed max_messages limit)
  Server-side context editing API handles trimming, so we send full history

This ensures messages sent via the message() tool (e.g., from heartbeat forks)
are visible in the main conversational agent's context.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-22 00:05:56 +01:00
21 changed files with 135 additions and 1432 deletions
+14 -42
View File
@@ -155,8 +155,7 @@ class AgentLoop:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}",
metadata=msg.metadata or {},
content=f"Sorry, I encountered an error: {str(e)}"
))
except asyncio.TimeoutError:
continue
@@ -284,16 +283,13 @@ class AgentLoop:
session.clear()
self.sessions.save(session)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 New session started. Memory consolidated.",
metadata=msg.metadata or {})
content="🐈 New session started. Memory consolidated.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status",
metadata=msg.metadata or {})
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
if cmd == "/quota":
status = self._get_quota_status()
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status,
metadata=msg.metadata or {})
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
# Update tool contexts
message_tool = self.tools.get("message")
@@ -313,12 +309,6 @@ class AgentLoop:
tz = time.strftime("%Z") or "UTC"
time_str = now_dt.strftime("%Y-%m-%d %H:%M (%A)")
current_message = f"[Current time: {time_str} {tz}]\n{msg.content}"
# Prefix hook messages so the agent can identify them
hook_source = msg.metadata.get("hook_source") if msg.metadata else None
if hook_source:
current_message = f'[HOOK MESSAGE from "{hook_source}"]\n{current_message}'
last_user_ts = None
for m in reversed(session.messages):
if m.get("role") == "user":
@@ -416,39 +406,25 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
# Check for suppress mode BEFORE adding to session
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
if suppress_output:
# Prefix content for session visibility
final_content_for_session = f"[HIDDEN] {final_content}"
# Mark as suppressed for channel handler
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
else:
final_content_for_session = final_content
outbound_metadata = msg.metadata or {}
# Append final assistant response to messages so it's captured in the tool chain slice
# Use the prefixed version for session storage
messages = self.context.add_assistant_message(
messages, final_content_for_session, None,
messages, final_content, None,
reasoning_content=final_reasoning,
)
# Save to session: user message + full tool chain (tool_use, tool_results, thinking, final reply)
# Store current_message (not msg.content) so the time prefix is preserved
# and cache keys match on subsequent turns
# Include sender_id to distinguish real user messages from system-generated ones
session.add_message("user", current_message, sender_id=msg.sender_id)
session.add_message("user", current_message)
for chain_msg in messages[turn_start:]:
session.add_raw_message(chain_msg)
self.sessions.save(session)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content_for_session,
metadata=outbound_metadata,
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
@@ -564,8 +540,7 @@ class AgentLoop:
return OutboundMessage(
channel=origin_channel,
chat_id=origin_chat_id,
content=final_content,
metadata=msg.metadata or {},
content=final_content
)
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
@@ -675,18 +650,16 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
metadata: dict[str, Any] | None = None,
) -> str:
"""
Process a message directly (for CLI or cron usage).
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
metadata: Optional metadata to pass through (for suppress mode, etc.).
Returns:
The agent's response.
"""
@@ -694,9 +667,8 @@ Respond with ONLY valid JSON, no markdown fences."""
channel=channel,
sender_id="user",
chat_id=chat_id,
content=content,
metadata=metadata or {},
content=content
)
response = await self._process_message(msg, session_key=session_key)
return response.content if response else ""
+1 -23
View File
@@ -20,7 +20,6 @@ class MessageBus:
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
self._correlation_store: dict[str, asyncio.Future] = {}
self._running = False
async def publish_inbound(self, msg: InboundMessage) -> None:
@@ -38,28 +37,7 @@ class MessageBus:
async def consume_outbound(self) -> OutboundMessage:
"""Consume the next outbound message (blocks until available)."""
return await self.outbound.get()
def register_correlation(self, correlation_id: str) -> asyncio.Future:
"""Register a Future to be resolved when a matching outbound message appears."""
loop = asyncio.get_running_loop()
future = loop.create_future()
self._correlation_store[correlation_id] = future
return future
def resolve_correlation(self, msg: OutboundMessage) -> None:
"""Check if an outbound message has a correlation_id and resolve the matching Future."""
cid = msg.metadata.get("correlation_id") if msg.metadata else None
if cid and cid in self._correlation_store:
future = self._correlation_store.pop(cid)
if not future.done():
future.set_result(msg.content)
def cancel_correlation(self, correlation_id: str) -> None:
"""Cancel and remove a pending correlation."""
future = self._correlation_store.pop(correlation_id, None)
if future and not future.done():
future.cancel()
def subscribe_outbound(
self,
channel: str,
-38
View File
@@ -1,38 +0,0 @@
"""Hook channel — receives outbound messages from hook-initiated conversations."""
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
class HookChannel:
"""
Minimal channel for hook-initiated conversations.
The hook HTTP server publishes InboundMessages to the bus.
Responses come back as OutboundMessages routed here.
send() is a no-op because the HTTP caller gets the response
via bus correlation, not channel delivery.
"""
name = "hook"
def __init__(self, bus: MessageBus):
self.bus = bus
self._running = False
async def start(self) -> None:
self._running = True
logger.info("Hook channel started")
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""No-op — response is returned via bus correlation to the HTTP caller."""
logger.debug(f"Hook channel received outbound for {msg.chat_id} (no-op)")
@property
def is_running(self) -> bool:
return self._running
+1 -9
View File
@@ -137,11 +137,6 @@ class ChannelManager:
except ImportError as e:
logger.warning(f"QQ channel not available: {e}")
def register_channel(self, name: str, channel: BaseChannel) -> None:
"""Register an external channel."""
self.channels[name] = channel
logger.info(f"{name} channel registered")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
try:
@@ -197,10 +192,7 @@ class ChannelManager:
self.bus.consume_outbound(),
timeout=1.0
)
# Resolve any pending correlation (hook request-response)
self.bus.resolve_correlation(msg)
channel = self.channels.get(msg.channel)
if channel:
try:
+1 -6
View File
@@ -185,14 +185,9 @@ class TelegramChannel(BaseChannel):
if not self._app:
logger.warning("Telegram bot not running")
return
# Stop typing indicator for this chat
self._stop_typing(msg.chat_id)
# Check for suppression
if msg.metadata.get("suppressed", False):
logger.debug(f"Suppressed output (not sent to Telegram): {msg.content[:100]}...")
return # Don't send to Telegram API
try:
# chat_id should be the Telegram chat ID (integer)
+90 -126
View File
@@ -2,24 +2,23 @@
import asyncio
import os
import select
import signal
import sys
from pathlib import Path
from typing import Any
import select
import sys
import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
from nanobot.cli.oauth import oauth_app
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
app = typer.Typer(
name="nanobot",
@@ -159,26 +158,26 @@ def onboard():
from nanobot.config.loader import get_config_path, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
config_path = get_config_path()
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
if not typer.confirm("Overwrite?"):
raise typer.Exit()
# Create default config
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
_create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
@@ -230,13 +229,13 @@ Information about the user goes here.
- Language: (your preferred language)
""",
}
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
@@ -259,7 +258,7 @@ This file stores important information that should persist across sessions.
(Things to remember)
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("")
@@ -300,30 +299,30 @@ def gateway(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the nanobot gateway."""
from nanobot.agent.loop import AgentLoop
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.config.loader import get_data_dir, load_config
from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
agent = AgentLoop(
bus=bus,
@@ -338,7 +337,7 @@ def gateway(
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
@@ -357,82 +356,48 @@ def gateway(
))
return response
cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str, metadata: dict[str, Any] | None = None) -> str:
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(
prompt,
session_key="telegram:239824268", # Run in main telegram session
channel="telegram",
chat_id="239824268",
metadata=metadata,
)
return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True,
session_manager=session_manager, # Pass session manager
target_session_key="telegram:239824268", # Target session
idle_threshold_s=30 * 60, # 30 minutes idle
enabled=True
)
# Create channel manager
channels = ChannelManager(config, bus)
# Create hooks server
from nanobot.channels.hook import HookChannel
from nanobot.hooks.server import HooksServer
hooks_config = config.hooks if hasattr(config, 'hooks') else None
hooks_server = None
if hooks_config and hooks_config.enabled:
# Register hook channel
hook_channel = HookChannel(bus)
channels.register_channel("hook", hook_channel)
# Create hooks server (checks has_tokens internally)
hooks_server = HooksServer(
host=config.gateway.host,
port=config.gateway.port,
config=hooks_config,
bus=bus,
)
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print("[green]✓[/green] Heartbeat: every 30m")
console.print(f"[green]✓[/green] Heartbeat: every 30m")
async def run():
try:
await cron.start()
await heartbeat.start()
if hooks_server:
await hooks_server.start()
await asyncio.gather(
agent.run(),
channels.start_all(),
)
except KeyboardInterrupt:
console.print("\nShutting down...")
if hooks_server:
await hooks_server.stop()
heartbeat.stop()
cron.stop()
agent.stop()
await channels.stop_all()
asyncio.run(run())
@@ -451,14 +416,13 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from loguru import logger
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
@@ -466,7 +430,7 @@ def agent(
logger.enable("nanobot")
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
@@ -478,7 +442,7 @@ def agent(
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
def _thinking_ctx():
if logs:
@@ -493,7 +457,7 @@ def agent(
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
asyncio.run(run_once())
else:
# Interactive mode
@@ -506,7 +470,7 @@ def agent(
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
while True:
try:
@@ -520,7 +484,7 @@ def agent(
_restore_terminal()
console.print("\nGoodbye!")
break
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
@@ -532,7 +496,7 @@ def agent(
_restore_terminal()
console.print("\nGoodbye!")
break
asyncio.run(run_interactive())
@@ -544,6 +508,7 @@ def agent(
channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")
from nanobot.cli.oauth import oauth_app
app.add_typer(oauth_app, name="oauth")
@@ -591,7 +556,7 @@ def channels_status():
"" if mc.enabled else "",
mc_base
)
# Telegram
tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
@@ -617,57 +582,57 @@ def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed."""
import shutil
import subprocess
# User's bridge location
user_bridge = Path.home() / ".nanobot" / "bridge"
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
return user_bridge
# Check for npm
if not shutil.which("npm"):
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
raise typer.Exit(1)
# Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
console.print(f"{__logo__} Setting up bridge...")
# Copy to user directory
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
# Install and build
try:
console.print(" Installing dependencies...")
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
console.print(" Building...")
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
console.print("[green]✓[/green] Bridge ready\n")
except subprocess.CalledProcessError as e:
console.print(f"[red]Build failed: {e}[/red]")
if e.stderr:
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
raise typer.Exit(1)
return user_bridge
@@ -675,19 +640,18 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
from nanobot.config.loader import load_config
config = load_config()
bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e:
@@ -711,23 +675,23 @@ def cron_list(
"""List scheduled jobs."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
jobs = service.list_jobs(include_disabled=all)
if not jobs:
console.print("No scheduled jobs.")
return
table = Table(title="Scheduled Jobs")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Schedule")
table.add_column("Status")
table.add_column("Next Run")
import time
for job in jobs:
# Format schedule
@@ -737,17 +701,17 @@ def cron_list(
sched = job.schedule.expr or ""
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
next_run = next_time
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
table.add_row(job.id, job.name, sched, status, next_run)
console.print(table)
@@ -766,7 +730,7 @@ def cron_add(
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
@@ -779,10 +743,10 @@ def cron_add(
else:
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
raise typer.Exit(1)
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.add_job(
name=name,
schedule=schedule,
@@ -791,7 +755,7 @@ def cron_add(
to=to,
channel=channel,
)
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@@ -802,10 +766,10 @@ def cron_remove(
"""Remove a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
@@ -820,10 +784,10 @@ def cron_enable(
"""Enable or disable a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
@@ -840,15 +804,15 @@ def cron_run(
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
async def run():
return await service.run_job(job_id, force=force)
if asyncio.run(run()):
console.print("[green]✓[/green] Job executed")
console.print(f"[green]✓[/green] Job executed")
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
@@ -861,7 +825,7 @@ def cron_run(
@app.command()
def status():
"""Show nanobot status."""
from nanobot.config.loader import get_config_path, load_config
from nanobot.config.loader import load_config, get_config_path
config_path = get_config_path()
config = load_config()
@@ -876,7 +840,7 @@ def status():
from nanobot.providers.registry import PROVIDERS
console.print(f"Model: {config.agents.defaults.model}")
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
-21
View File
@@ -230,26 +230,6 @@ class GatewayConfig(BaseModel):
port: int = 18790
class HooksConfig(BaseModel):
"""Webhook endpoint configuration."""
enabled: bool = False
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
path: str = "/hooks" # URL path for the endpoint
timeout_seconds: int = 120 # Max time to wait for agent response
def resolve_token(self, provided: str) -> str | None:
"""Return token name if provided secret matches, else None."""
for name, secret in self.tokens.items():
if secret == provided:
return name
return None
@property
def has_tokens(self) -> bool:
"""True if at least one token is configured."""
return bool(self.tokens)
class WebSearchConfig(BaseModel):
"""Web search tool configuration."""
api_key: str = "" # Brave Search API key
@@ -279,7 +259,6 @@ class Config(BaseSettings):
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
hooks: HooksConfig = Field(default_factory=HooksConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
@property
+28 -74
View File
@@ -1,16 +1,11 @@
"""Heartbeat service - periodic agent wake-up to check for tasks."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine
from typing import Any, Callable, Coroutine
from loguru import logger
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
# Default interval: 30 minutes
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
@@ -27,51 +22,45 @@ def _is_heartbeat_empty(content: str | None) -> bool:
"""Check if HEARTBEAT.md has no actionable content."""
if not content:
return True
# Lines to skip: empty, headers, HTML comments, empty checkboxes
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
for line in content.split("\n"):
line = line.strip()
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
continue
return False # Found actionable content
return True
class HeartbeatService:
"""
Periodic heartbeat service that wakes the agent to check for tasks.
The agent reads HEARTBEAT.md from the workspace and executes any
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
"""
def __init__(
self,
workspace: Path,
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
enabled: bool = True,
session_manager: SessionManager | None = None,
target_session_key: str = "telegram:239824268",
idle_threshold_s: int = 30 * 60, # 30 minutes
):
self.workspace = workspace
self.on_heartbeat = on_heartbeat
self.interval_s = interval_s
self.enabled = enabled
self.session_manager = session_manager
self.target_session_key = target_session_key
self.idle_threshold_s = idle_threshold_s
self._running = False
self._task: asyncio.Task | None = None
@property
def heartbeat_file(self) -> Path:
return self.workspace / "HEARTBEAT.md"
def _read_heartbeat_file(self) -> str | None:
"""Read HEARTBEAT.md content."""
if self.heartbeat_file.exists():
@@ -80,24 +69,24 @@ class HeartbeatService:
except Exception:
return None
return None
async def start(self) -> None:
"""Start the heartbeat service."""
if not self.enabled:
logger.info("Heartbeat disabled")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info(f"Heartbeat started (every {self.interval_s}s)")
def stop(self) -> None:
"""Stop the heartbeat service."""
self._running = False
if self._task:
self._task.cancel()
self._task = None
async def _run_loop(self) -> None:
"""Main heartbeat loop."""
while self._running:
@@ -109,68 +98,33 @@ class HeartbeatService:
break
except Exception as e:
logger.error(f"Heartbeat error: {e}")
async def _tick(self) -> None:
"""Execute a single heartbeat tick."""
# Check if user is idle (if session manager provided)
if self.session_manager and self.target_session_key:
try:
session = self.session_manager.get_or_create(self.target_session_key)
# Find last real user message timestamp (exclude system-generated messages)
# Real Telegram messages have sender_id like "239824268|username"
# System messages (heartbeat, cron) created via process_direct have sender_id="user"
# Old messages may not have sender_id field (backwards compat: treat as real user messages)
last_user_timestamp = None
for msg in reversed(session.messages):
if msg.get("role") == "user":
sender_id = msg.get("sender_id")
# Skip if explicitly marked as system-generated
if sender_id == "user":
continue
# Accept if no sender_id (old message) or if real user ID
last_user_timestamp = msg.get("timestamp")
break
if last_user_timestamp:
from datetime import datetime
last_dt = datetime.fromisoformat(last_user_timestamp)
elapsed = (datetime.now() - last_dt).total_seconds()
if elapsed < self.idle_threshold_s:
logger.debug(f"Heartbeat: user active {int(elapsed)}s ago, skipping")
return # User is active, don't trigger heartbeat
except Exception as e:
logger.warning(f"Heartbeat: error checking idle state: {e}")
# Continue with heartbeat on error (fail open)
# Original heartbeat logic
content = self._read_heartbeat_file()
# Skip if HEARTBEAT.md is empty or doesn't exist
if _is_heartbeat_empty(content):
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
return
logger.info("Heartbeat: user idle, checking for tasks...")
logger.info("Heartbeat: checking for tasks...")
if self.on_heartbeat:
try:
# Call with suppress_output metadata
await self.on_heartbeat(
HEARTBEAT_PROMPT,
metadata={"suppress_output": True}
)
# Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
logger.info("Heartbeat: completed")
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
# Check if agent said "nothing to do"
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
logger.info("Heartbeat: OK (no action needed)")
else:
logger.info(f"Heartbeat: completed task")
except Exception as e:
logger.error(f"Heartbeat execution failed: {e}")
async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat."""
if self.on_heartbeat:
return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
return await self.on_heartbeat(HEARTBEAT_PROMPT)
return None
View File
-139
View File
@@ -1,139 +0,0 @@
"""HTTP hooks server for external service integration."""
import asyncio
import json
import uuid
from aiohttp import web
from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import HooksConfig
class HooksServer:
"""
HTTP server exposing a /hooks endpoint.
External services POST JSON messages. The server publishes them
to the bus as InboundMessages and uses bus-level correlation
to return the agent's response synchronously.
"""
def __init__(
self,
host: str,
port: int,
config: HooksConfig,
bus: MessageBus,
):
self.host = host
self.port = port
self.config = config
self.bus = bus
self._app = web.Application()
self._app.router.add_post(self.config.path, self._handle_hook)
self._app.router.add_get("/health", self._handle_health)
self._runner: web.AppRunner | None = None
async def start(self) -> None:
"""Start the HTTP server."""
if not self.config.has_tokens:
logger.warning("Hooks server has no tokens configured — endpoint disabled for security")
return
self._runner = web.AppRunner(self._app)
await self._runner.setup()
site = web.TCPSite(self._runner, self.host, self.port)
await site.start()
logger.info(f"Hooks server listening on {self.host}:{self.port}{self.config.path}")
async def stop(self) -> None:
"""Stop the HTTP server."""
if self._runner:
await self._runner.cleanup()
self._runner = None
def _resolve_auth(self, request: web.Request) -> str | None:
"""
Validate auth and return token name if valid, None otherwise.
Checks Authorization: Bearer <token> and X-Hook-Token headers.
"""
# Try Authorization: Bearer <token>
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
else:
# Try X-Hook-Token header
token = request.headers.get("X-Hook-Token", "")
return self.config.resolve_token(token) if token else None
async def _handle_health(self, request: web.Request) -> web.Response:
"""Health check endpoint — no auth required."""
return web.json_response({"status": "ok"})
async def _handle_hook(self, request: web.Request) -> web.Response:
"""Handle incoming hook request."""
# Auth check — resolve token name
token_name = self._resolve_auth(request)
if not token_name:
return web.json_response({"error": "unauthorized"}, status=401)
# Parse body
try:
body = await request.json()
except (json.JSONDecodeError, Exception):
return web.json_response({"error": "invalid JSON body"}, status=400)
# Validate required fields
message = body.get("message")
if not message or not isinstance(message, str):
return web.json_response(
{"error": "missing or invalid 'message' field"}, status=400
)
# Optional fields
channel = body.get("channel", "hook")
chat_id = body.get("chat_id", token_name)
timeout = body.get("timeout", self.config.timeout_seconds)
# Create correlation
correlation_id = str(uuid.uuid4())
# Build InboundMessage
msg = InboundMessage(
channel=channel,
sender_id=f"hook:{token_name}",
chat_id=str(chat_id),
content=message,
metadata={
"correlation_id": correlation_id,
"hook_source": token_name,
},
)
# Fire-and-forget mode
if timeout == 0:
await self.bus.publish_inbound(msg)
return web.json_response({"ok": True}, status=202)
# Request-response mode
future = self.bus.register_correlation(correlation_id)
await self.bus.publish_inbound(msg)
try:
response = await asyncio.wait_for(future, timeout=timeout)
return web.json_response({"ok": True, "response": response})
except asyncio.TimeoutError:
return web.json_response(
{"ok": False, "error": f"agent did not respond within {timeout}s"},
status=504,
)
except Exception as e:
logger.error(f"Hook processing error: {e}")
return web.json_response({"error": "internal error"}, status=500)
finally:
# Clean up correlation on any failure
self.bus.cancel_correlation(correlation_id)
-96
View File
@@ -1,96 +0,0 @@
# tests/test_agent_loop_metadata.py
import pytest
from pathlib import Path
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider, LLMResponse
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_process_direct_passes_metadata():
"""Test that process_direct passes metadata to InboundMessage."""
bus = MessageBus()
provider = MagicMock(spec=LLMProvider)
provider.chat = AsyncMock(return_value=LLMResponse(
content="test response",
tool_calls=[]
))
provider.get_default_model = MagicMock(return_value="test-model")
provider.thinking_budget = 0
workspace = Path("/tmp/test-workspace")
workspace.mkdir(exist_ok=True)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
# Call with metadata
test_metadata = {"suppress_output": True, "test_key": "test_value"}
await loop.process_direct(
content="test message",
metadata=test_metadata
)
# Verify provider.chat was called
assert provider.chat.called
call_args = provider.chat.call_args
messages = call_args.kwargs["messages"]
# The user message should contain the content
# (We can't easily check InboundMessage directly, but we verify
# the flow worked by checking the session was created)
session = loop.sessions.get_or_create("cli:direct")
assert len(session.messages) > 0
@pytest.mark.asyncio
async def test_suppress_mode_adds_hidden_prefix():
"""Test that suppress_output metadata adds [HIDDEN] prefix."""
bus = MessageBus()
provider = MagicMock(spec=LLMProvider)
provider.chat = AsyncMock(return_value=LLMResponse(
content="This is the agent response",
tool_calls=[]
))
provider.get_default_model = MagicMock(return_value="test-model")
provider.thinking_budget = 0
workspace = Path("/tmp/test-workspace")
workspace.mkdir(exist_ok=True)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
# Call with suppress_output=True
response = await loop.process_direct(
content="test message",
metadata={"suppress_output": True}
)
# Response content should have [HIDDEN] prefix
assert response.startswith("[HIDDEN]")
assert "This is the agent response" in response
@pytest.mark.asyncio
async def test_normal_mode_no_hidden_prefix():
"""Test that normal messages don't get [HIDDEN] prefix."""
bus = MessageBus()
provider = MagicMock(spec=LLMProvider)
provider.chat = AsyncMock(return_value=LLMResponse(
content="Normal response",
tool_calls=[]
))
provider.get_default_model = MagicMock(return_value="test-model")
provider.thinking_budget = 0
workspace = Path("/tmp/test-workspace")
workspace.mkdir(exist_ok=True)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
# Call without suppress_output
response = await loop.process_direct(content="test message")
# Response should NOT have [HIDDEN] prefix
assert not response.startswith("[HIDDEN]")
assert response == "Normal response"
-59
View File
@@ -1,59 +0,0 @@
"""Tests for bus-level correlation (request-response via Futures)."""
import asyncio
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.bus.events import OutboundMessage
@pytest.fixture
def bus():
return MessageBus()
@pytest.mark.asyncio
async def test_register_correlation_returns_future(bus):
future = bus.register_correlation("test-id-1")
assert isinstance(future, asyncio.Future)
assert not future.done()
@pytest.mark.asyncio
async def test_resolve_correlation_sets_future_result(bus):
future = bus.register_correlation("test-id-1")
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
bus.resolve_correlation(msg)
assert future.done()
assert future.result() == "hello"
@pytest.mark.asyncio
async def test_resolve_correlation_no_match_is_noop(bus):
future = bus.register_correlation("test-id-1")
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "other-id"})
bus.resolve_correlation(msg)
assert not future.done()
@pytest.mark.asyncio
async def test_resolve_correlation_no_metadata_is_noop(bus):
future = bus.register_correlation("test-id-1")
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
bus.resolve_correlation(msg)
assert not future.done()
@pytest.mark.asyncio
async def test_resolve_correlation_cleans_up_store(bus):
future = bus.register_correlation("test-id-1")
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
bus.resolve_correlation(msg)
assert "test-id-1" not in bus._correlation_store
@pytest.mark.asyncio
async def test_cancel_correlation(bus):
future = bus.register_correlation("test-id-1")
bus.cancel_correlation("test-id-1")
assert "test-id-1" not in bus._correlation_store
assert future.cancelled()
-95
View File
@@ -1,95 +0,0 @@
# tests/test_heartbeat_idle.py
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
@pytest.mark.asyncio
async def test_heartbeat_skips_when_user_active():
"""Test that heartbeat doesn't trigger if user messaged recently."""
workspace = Path("/tmp/test-heartbeat")
workspace.mkdir(exist_ok=True)
# Create session with recent user message
sessions = SessionManager(workspace)
session = sessions.get_or_create("telegram:239824268")
session.add_message("user", "Recent message")
sessions.save(session)
# Create heartbeat callback
callback_called = False
async def on_heartbeat(prompt, metadata=None):
nonlocal callback_called
callback_called = True
return "response"
# Create heartbeat service
service = HeartbeatService(
workspace=workspace,
on_heartbeat=on_heartbeat,
interval_s=1, # Short interval for testing
enabled=True,
session_manager=sessions,
target_session_key="telegram:239824268"
)
# Trigger heartbeat
await service._tick()
# Callback should NOT have been called (user was active recently)
assert not callback_called
@pytest.mark.asyncio
async def test_heartbeat_triggers_when_user_idle():
"""Test that heartbeat triggers after 30min of inactivity."""
workspace = Path("/tmp/test-heartbeat")
workspace.mkdir(exist_ok=True)
# Create HEARTBEAT.md with content
heartbeat_file = workspace / "HEARTBEAT.md"
heartbeat_file.write_text("# Tasks\n- Check something\n")
# Create session with old user message (>30min ago)
sessions = SessionManager(workspace)
session = sessions.get_or_create("telegram:239824268")
# Manually set old timestamp
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
session.messages.append({
"role": "user",
"content": "Old message",
"timestamp": old_timestamp
})
sessions.save(session)
# Create heartbeat callback
callback_called = False
callback_metadata = None
async def on_heartbeat(prompt, metadata=None):
nonlocal callback_called, callback_metadata
callback_called = True
callback_metadata = metadata
return "response"
# Create heartbeat service
service = HeartbeatService(
workspace=workspace,
on_heartbeat=on_heartbeat,
interval_s=1,
enabled=True,
session_manager=sessions,
target_session_key="telegram:239824268"
)
# Trigger heartbeat
await service._tick()
# Callback SHOULD have been called (user idle for >30min)
assert callback_called
assert callback_metadata == {"suppress_output": True}
-146
View File
@@ -1,146 +0,0 @@
# tests/test_heartbeat_idle_detection.py
"""Tests for heartbeat idle detection with sender_id filtering."""
import pytest
from datetime import datetime, timedelta
from pathlib import Path
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import Session, SessionManager
@pytest.mark.asyncio
async def test_idle_detection_ignores_system_messages():
"""Test that heartbeat only counts real user messages for idle detection."""
# Create session manager and session
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
session = session_manager.get_or_create("telegram:239824268")
# Add a real user message 45 minutes ago
real_user_time = datetime.now() - timedelta(minutes=45)
session.add_message(
"user",
"This is a real user message",
sender_id="239824268|testuser",
timestamp=real_user_time.isoformat()
)
# Add a heartbeat system message 10 minutes ago (should be ignored)
heartbeat_time = datetime.now() - timedelta(minutes=10)
session.add_message(
"user",
"Read HEARTBEAT.md...",
sender_id="user",
timestamp=heartbeat_time.isoformat()
)
# Add an assistant response
session.add_message("assistant", "Response to heartbeat")
# Create heartbeat service with 30 minute idle threshold
heartbeat = HeartbeatService(
workspace=Path("/tmp/test-heartbeat"),
session_manager=session_manager,
target_session_key="telegram:239824268",
idle_threshold_s=30 * 60, # 30 minutes
interval_s=30 * 60,
enabled=False # Don't actually start the loop
)
# Manually check idle logic (replicate _tick logic)
session = session_manager.get_or_create("telegram:239824268")
# Find last user message timestamp (should find the 45-minute-old message, not the 10-minute-old one)
last_user_timestamp = None
for msg in reversed(session.messages):
if msg.get("role") == "user":
sender_id = msg.get("sender_id")
if sender_id == "user":
continue
last_user_timestamp = msg.get("timestamp")
break
assert last_user_timestamp is not None
last_dt = datetime.fromisoformat(last_user_timestamp)
elapsed = (datetime.now() - last_dt).total_seconds()
# Should detect user is idle (45 minutes > 30 minute threshold)
assert elapsed >= 30 * 60, f"Expected idle (45min), but elapsed={elapsed/60:.1f}min"
# Should NOT be 10 minutes (heartbeat message was ignored)
assert elapsed >= 40 * 60, f"Heartbeat message was not ignored, elapsed={elapsed/60:.1f}min"
@pytest.mark.asyncio
async def test_idle_detection_counts_real_user_messages():
"""Test that heartbeat correctly identifies when user is active."""
# Create session manager and session
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
session = session_manager.get_or_create("telegram:239824268")
# Add a real user message 10 minutes ago (recent activity)
real_user_time = datetime.now() - timedelta(minutes=10)
session.add_message(
"user",
"This is a recent user message",
sender_id="239824268|testuser",
timestamp=real_user_time.isoformat()
)
# Create heartbeat service with 30 minute idle threshold
heartbeat = HeartbeatService(
workspace=Path("/tmp/test-heartbeat"),
session_manager=session_manager,
target_session_key="telegram:239824268",
idle_threshold_s=30 * 60, # 30 minutes
interval_s=30 * 60,
enabled=False
)
# Find last user message timestamp
session = session_manager.get_or_create("telegram:239824268")
last_user_timestamp = None
for msg in reversed(session.messages):
if msg.get("role") == "user":
sender_id = msg.get("sender_id")
if sender_id == "user":
continue
last_user_timestamp = msg.get("timestamp")
break
assert last_user_timestamp is not None
last_dt = datetime.fromisoformat(last_user_timestamp)
elapsed = (datetime.now() - last_dt).total_seconds()
# Should detect user is active (10 minutes < 30 minute threshold)
assert elapsed < 30 * 60, f"Expected active (10min), but elapsed={elapsed/60:.1f}min"
@pytest.mark.asyncio
async def test_backwards_compat_messages_without_sender_id():
"""Test that old messages without sender_id are treated as real user messages."""
# Create session manager and session
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
session = session_manager.get_or_create("telegram:239824268")
# Add an old message without sender_id (backwards compat)
old_time = datetime.now() - timedelta(minutes=20)
session.add_message(
"user",
"Old message without sender_id",
timestamp=old_time.isoformat()
)
# Find last user message timestamp (should find the old message)
last_user_timestamp = None
for msg in reversed(session.messages):
if msg.get("role") == "user":
sender_id = msg.get("sender_id")
if sender_id == "user":
continue
last_user_timestamp = msg.get("timestamp")
break
assert last_user_timestamp is not None
last_dt = datetime.fromisoformat(last_user_timestamp)
elapsed = (datetime.now() - last_dt).total_seconds()
# Should accept old message (backwards compat)
assert elapsed < 25 * 60, f"Old message not counted, elapsed={elapsed/60:.1f}min"
-37
View File
@@ -1,37 +0,0 @@
"""Tests for the hook channel."""
import pytest
from unittest.mock import MagicMock
from nanobot.channels.hook import HookChannel
from nanobot.bus.queue import MessageBus
from nanobot.bus.events import OutboundMessage
@pytest.fixture
def bus():
return MessageBus()
def test_hook_channel_name():
bus = MessageBus()
channel = HookChannel(bus)
assert channel.name == "hook"
@pytest.mark.asyncio
async def test_hook_channel_send_is_noop():
"""send() should not raise and should not do anything."""
bus = MessageBus()
channel = HookChannel(bus)
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
await channel.send(msg) # Should not raise
@pytest.mark.asyncio
async def test_hook_channel_start_stop():
bus = MessageBus()
channel = HookChannel(bus)
await channel.start()
assert channel.is_running
await channel.stop()
assert not channel.is_running
-24
View File
@@ -1,24 +0,0 @@
"""Tests for HooksConfig with named tokens."""
from nanobot.config.schema import HooksConfig
def test_hooks_config_named_tokens():
"""Named tokens dict should work."""
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
assert config.tokens == {"gitea": "secret1", "ha": "secret2"}
def test_hooks_config_resolve_token():
"""resolve_token should return token name for a matching token."""
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
assert config.resolve_token("secret1") == "gitea"
assert config.resolve_token("secret2") == "ha"
assert config.resolve_token("unknown") is None
def test_hooks_config_has_tokens():
"""has_tokens should be True if tokens dict is non-empty."""
assert HooksConfig(enabled=True, tokens={"webhook": "secret"}).has_tokens
assert not HooksConfig(enabled=True).has_tokens
assert not HooksConfig(enabled=True, tokens={}).has_tokens
-128
View File
@@ -1,128 +0,0 @@
"""End-to-end integration test for hooks → bus → correlation → response."""
import asyncio
import pytest
from aiohttp.test_utils import TestClient, TestServer
from nanobot.hooks.server import HooksServer
from nanobot.bus.queue import MessageBus
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.config.schema import HooksConfig
from nanobot.channels.hook import HookChannel
@pytest.fixture
def bus():
return MessageBus()
@pytest.fixture
def config():
return HooksConfig(
enabled=True,
tokens={"gitea": "gitea-secret", "ha": "ha-secret"},
timeout_seconds=5,
)
@pytest.fixture
def server(bus, config):
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
async def fake_agent_loop(bus: MessageBus):
"""Simulate agent loop: consume inbound, process, publish outbound."""
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=3.0)
response_content = f"Processed: {msg.content}"
await bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=response_content,
metadata=msg.metadata or {},
))
async def fake_dispatch_loop(bus: MessageBus, hook_channel: HookChannel):
"""Simulate outbound dispatcher: consume outbound, resolve correlation, dispatch."""
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=3.0)
bus.resolve_correlation(msg)
if msg.channel == "hook":
await hook_channel.send(msg)
@pytest.mark.asyncio
async def test_full_hook_flow_default_channel(server, bus):
"""Hook with default channel: message goes through bus, response returned to HTTP caller."""
hook_channel = HookChannel(bus)
client = TestClient(TestServer(server._app))
async with client:
async def do_request():
return await client.post(
"/hooks",
json={"message": "deploy started"},
headers={"Authorization": "Bearer gitea-secret"},
)
# Run request + fake agent + fake dispatcher concurrently
request_task = asyncio.create_task(do_request())
agent_task = asyncio.create_task(fake_agent_loop(bus))
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
resp = await asyncio.wait_for(request_task, timeout=5.0)
await agent_task
await dispatch_task
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert "deploy started" in data["response"]
@pytest.mark.asyncio
async def test_full_hook_flow_telegram_channel(server, bus):
"""Hook targeting telegram: uses telegram session, response still returned to HTTP caller."""
hook_channel = HookChannel(bus)
client = TestClient(TestServer(server._app))
async with client:
async def do_request():
return await client.post(
"/hooks",
json={"message": "doorbell rang", "channel": "telegram", "chat_id": "239824268"},
headers={"Authorization": "Bearer ha-secret"},
)
request_task = asyncio.create_task(do_request())
agent_task = asyncio.create_task(fake_agent_loop(bus))
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
resp = await asyncio.wait_for(request_task, timeout=5.0)
await agent_task
await dispatch_task
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert "doorbell rang" in data["response"]
@pytest.mark.asyncio
async def test_named_token_identification(server, bus):
"""Different tokens should produce different hook_source in metadata."""
client = TestClient(TestServer(server._app))
async with client:
asyncio.create_task(client.post(
"/hooks",
json={"message": "from gitea"},
headers={"Authorization": "Bearer gitea-secret"},
))
msg1 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert msg1.metadata["hook_source"] == "gitea"
assert msg1.chat_id == "gitea"
asyncio.create_task(client.post(
"/hooks",
json={"message": "from ha"},
headers={"Authorization": "Bearer ha-secret"},
))
msg2 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert msg2.metadata["hook_source"] == "ha"
assert msg2.chat_id == "ha"
-176
View File
@@ -1,176 +0,0 @@
"""Tests for the rewritten hooks server."""
import asyncio
import json
import pytest
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop, TestClient, TestServer
from nanobot.hooks.server import HooksServer
from nanobot.bus.queue import MessageBus
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.config.schema import HooksConfig
@pytest.fixture
def bus():
return MessageBus()
@pytest.fixture
def config():
return HooksConfig(
enabled=True,
tokens={"test-hook": "test-secret-123"},
timeout_seconds=5,
)
@pytest.fixture
def server(bus, config):
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
@pytest.mark.asyncio
async def test_health_check(server):
client = TestClient(TestServer(server._app))
async with client:
resp = await client.get("/health")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
@pytest.mark.asyncio
async def test_unauthorized_without_token(server):
client = TestClient(TestServer(server._app))
async with client:
resp = await client.post("/hooks", json={"message": "test"})
assert resp.status == 401
@pytest.mark.asyncio
async def test_unauthorized_wrong_token(server):
client = TestClient(TestServer(server._app))
async with client:
resp = await client.post(
"/hooks",
json={"message": "test"},
headers={"Authorization": "Bearer wrong-token"},
)
assert resp.status == 401
@pytest.mark.asyncio
async def test_missing_message_field(server, bus):
client = TestClient(TestServer(server._app))
async with client:
resp = await client.post(
"/hooks",
json={"not_message": "test"},
headers={"Authorization": "Bearer test-secret-123"},
)
assert resp.status == 400
@pytest.mark.asyncio
async def test_hook_publishes_to_bus(server, bus):
"""Hook should publish InboundMessage to bus and the message should contain hook prefix."""
client = TestClient(TestServer(server._app))
async with client:
# Send hook request in background (it will block waiting for correlation)
async def send_request():
return await client.post(
"/hooks",
json={"message": "hello from webhook"},
headers={"Authorization": "Bearer test-secret-123"},
)
task = asyncio.create_task(send_request())
# Consume the inbound message
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert msg.channel == "hook"
assert msg.chat_id == "test-hook" # defaults to token name
assert msg.metadata.get("hook_source") == "test-hook"
assert msg.metadata.get("correlation_id") is not None
# Simulate agent response by resolving correlation
bus.resolve_correlation(OutboundMessage(
channel="hook",
chat_id="test-hook",
content="agent says hi",
metadata={"correlation_id": msg.metadata["correlation_id"]},
))
resp = await asyncio.wait_for(task, timeout=2.0)
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert data["response"] == "agent says hi"
@pytest.mark.asyncio
async def test_hook_with_custom_channel(server, bus):
"""Hook targeting telegram should use telegram channel in InboundMessage."""
client = TestClient(TestServer(server._app))
async with client:
async def send_request():
return await client.post(
"/hooks",
json={"message": "notify user", "channel": "telegram", "chat_id": "239824268"},
headers={"Authorization": "Bearer test-secret-123"},
)
task = asyncio.create_task(send_request())
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert msg.channel == "telegram"
assert msg.chat_id == "239824268"
assert msg.session_key == "telegram:239824268"
bus.resolve_correlation(OutboundMessage(
channel="telegram",
chat_id="239824268",
content="done",
metadata={"correlation_id": msg.metadata["correlation_id"]},
))
resp = await asyncio.wait_for(task, timeout=2.0)
assert resp.status == 200
data = await resp.json()
assert data["response"] == "done"
@pytest.mark.asyncio
async def test_hook_timeout_returns_504(bus):
"""If agent doesn't respond in time, return 504."""
config = HooksConfig(enabled=True, tokens={"test-hook": "test-secret-123"}, timeout_seconds=1)
server = HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
client = TestClient(TestServer(server._app))
async with client:
resp = await client.post(
"/hooks",
json={"message": "slow request"},
headers={"Authorization": "Bearer test-secret-123"},
)
assert resp.status == 504
@pytest.mark.asyncio
async def test_hook_timeout_zero_returns_202(server, bus):
"""timeout=0 should return 202 immediately without waiting."""
client = TestClient(TestServer(server._app))
async with client:
resp = await client.post(
"/hooks",
json={"message": "fire and forget", "timeout": 0},
headers={"Authorization": "Bearer test-secret-123"},
)
assert resp.status == 202
data = await resp.json()
assert data["ok"] is True
# Message should still be on the bus
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
assert msg.content == "fire and forget"
-104
View File
@@ -1,104 +0,0 @@
# tests/test_idle_heartbeat_integration.py
import pytest
import asyncio
from pathlib import Path
from datetime import datetime, timedelta
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
from nanobot.providers.base import LLMProvider, LLMResponse
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_idle_heartbeat_end_to_end(tmp_path):
"""
Integration test: heartbeat triggers when idle, runs in main session,
output is suppressed, session contains [HIDDEN] content.
"""
workspace = tmp_path / "test-integration"
workspace.mkdir()
# Use test-specific session key
test_session_key = "telegram:test_integration"
# Create HEARTBEAT.md with content
heartbeat_file = workspace / "HEARTBEAT.md"
heartbeat_file.write_text("# Test Task\n- Check something")
# Create mock provider
provider = MagicMock(spec=LLMProvider)
provider.chat = AsyncMock(return_value=LLMResponse(
content="Heartbeat executed successfully",
tool_calls=[] # has_tool_calls is a property, not a parameter
))
provider.get_default_model = MagicMock(return_value="test-model")
provider.thinking_budget = 0
# Create components
bus = MessageBus()
sessions = SessionManager(workspace)
# Override sessions_dir to use tmp_path for test isolation
sessions.sessions_dir = tmp_path / "sessions"
sessions.sessions_dir.mkdir()
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=workspace,
session_manager=sessions
)
# Create session with old user message
session = sessions.get_or_create(test_session_key)
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
session.messages.append({
"role": "user",
"content": "Old user message",
"timestamp": old_timestamp
})
sessions.save(session)
# Create heartbeat callback
async def on_heartbeat(prompt: str, metadata=None):
return await loop.process_direct(
prompt,
session_key=test_session_key,
channel="telegram",
chat_id="test_integration",
metadata=metadata,
)
# Create heartbeat service
heartbeat = HeartbeatService(
workspace=workspace,
on_heartbeat=on_heartbeat,
interval_s=1,
enabled=True,
session_manager=sessions,
target_session_key=test_session_key,
idle_threshold_s=30 * 60,
)
# Trigger heartbeat
await heartbeat._tick()
# Reload session from disk
sessions._cache.clear() # Clear cache to force reload
session = sessions.get_or_create(test_session_key)
# Verify:
# 1. Session has new messages
assert len(session.messages) > 1
# 2. Find the heartbeat response (assistant message)
heartbeat_messages = [
m for m in session.messages
if m.get("role") == "assistant" and "[HIDDEN]" in m.get("content", "")
]
assert len(heartbeat_messages) == 1, "Expected exactly 1 [HIDDEN] heartbeat message"
# 3. Verify content is prefixed with [HIDDEN]
heartbeat_msg = heartbeat_messages[0]
assert heartbeat_msg["content"].startswith("[HIDDEN]")
assert "Heartbeat executed successfully" in heartbeat_msg["content"]
-24
View File
@@ -1,24 +0,0 @@
"""Tests for correlation resolution in outbound dispatch."""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
from nanobot.bus.queue import MessageBus
from nanobot.bus.events import OutboundMessage
@pytest.mark.asyncio
async def test_dispatch_resolves_correlation_before_channel_send():
"""Correlation Future should be resolved when outbound message is dispatched."""
bus = MessageBus()
future = bus.register_correlation("corr-1")
msg = OutboundMessage(channel="telegram", chat_id="123", content="response", metadata={"correlation_id": "corr-1"})
await bus.publish_outbound(msg)
# Simulate what _dispatch_outbound does: consume + resolve
consumed = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
bus.resolve_correlation(consumed)
assert future.done()
assert future.result() == "response"
-65
View File
@@ -1,65 +0,0 @@
# tests/test_telegram_suppress.py
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels.telegram import TelegramChannel
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_suppressed_message_not_sent():
"""Test that messages with suppressed=True metadata are not sent to Telegram API."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the internal _app and bot directly (skip start())
mock_app = MagicMock()
mock_bot = AsyncMock()
mock_app.bot = mock_bot
channel._app = mock_app
# Send a suppressed message
msg = OutboundMessage(
channel="telegram",
chat_id="12345",
content="[HIDDEN] This should not be sent",
metadata={"suppressed": True}
)
await channel.send(msg)
# Verify bot.send_message was NOT called
mock_bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_normal_message_sent():
"""Test that normal messages are sent to Telegram API."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the internal _app and bot directly (skip start())
mock_app = MagicMock()
mock_bot = AsyncMock()
mock_app.bot = mock_bot
channel._app = mock_app
# Send a normal message
msg = OutboundMessage(
channel="telegram",
chat_id="12345",
content="Normal message",
metadata={}
)
await channel.send(msg)
# Verify bot.send_message WAS called
mock_bot.send_message.assert_called_once()