From 0c412b3728a430f3fed7daea753a23962ad84fa1 Mon Sep 17 00:00:00 2001 From: Yingwen Luo-LUOYW Date: Sun, 22 Feb 2026 23:13:09 +0800 Subject: [PATCH 01/13] feat(channels): add send_progress option to control progress message delivery Add a boolean config option `channels.sendProgress` (default: false) to control whether progress messages (marked with `_progress` metadata) are sent to chat channels. When disabled, progress messages are filtered out in the outbound dispatcher. --- nanobot/channels/manager.py | 3 +++ nanobot/config/schema.py | 1 + 2 files changed, 4 insertions(+) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 6fbab04..8a03883 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -193,6 +193,9 @@ class ChannelManager: timeout=1.0 ) + if msg.metadata.get("_progress") and not self.config.channels.send_progress: + continue + channel = self.channels.get(msg.channel) if channel: try: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 966d11d..bd602dc 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -168,6 +168,7 @@ class QQConfig(Base): class ChannelsConfig(Base): """Configuration for chat channels.""" + send_progress: bool = False whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) From 9025c7088fe834a75addc72efc00630174da911f Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:28:21 +0800 Subject: [PATCH 02/13] fix(heartbeat): route heartbeat runs to enabled chat context --- nanobot/cli/commands.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f1f9b30..b2f6bd3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -389,11 +389,36 @@ def gateway( return response cron.on_job = on_cron_job + # Create channel manager + channels = ChannelManager(config, bus) + + def _pick_heartbeat_target() -> tuple[str, str]: + """Pick a routable channel/chat target for heartbeat-triggered messages.""" + enabled = set(channels.enabled_channels) + # Prefer the most recently updated non-internal session on an enabled channel. + 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 + # Fallback keeps prior behavior but remains explicit. + return "cli", "direct" + # Create heartbeat service async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" - return await agent.process_direct(prompt, session_key="heartbeat") - + channel, chat_id = _pick_heartbeat_target() + return await agent.process_direct( + prompt, + session_key="heartbeat", + channel=channel, + chat_id=chat_id, + ) + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, @@ -401,9 +426,6 @@ def gateway( enabled=True ) - # Create channel manager - channels = ChannelManager(config, bus) - if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: From bc32e85c25f2366322626d6c8ff98574614be711 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 05:51:44 +0000 Subject: [PATCH 03/13] fix(memory): trigger consolidation by unconsolidated count, not total --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b05ba90..0bd05a8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -352,7 +352,8 @@ class AgentLoop: return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - if len(session.messages) > self.memory_window and session.key not in self._consolidating: + unconsolidated = len(session.messages) - session.last_consolidated + if (unconsolidated >= self.memory_window and session.key not in self._consolidating): self._consolidating.add(session.key) lock = self._get_consolidation_lock(session.key) From bfdae1b177ed486580416c768f7c91d059464eff Mon Sep 17 00:00:00 2001 From: yzchen Date: Mon, 23 Feb 2026 13:56:37 +0800 Subject: [PATCH 04/13] fix(heartbeat): make start idempotent and check exact OK token --- nanobot/heartbeat/service.py | 15 ++++++++++++- tests/test_heartbeat_service.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_heartbeat_service.py diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3c1a6aa..ab2bfac 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -1,6 +1,7 @@ """Heartbeat service - periodic agent wake-up to check for tasks.""" import asyncio +import re from pathlib import Path from typing import Any, Callable, Coroutine @@ -35,6 +36,15 @@ def _is_heartbeat_empty(content: str | None) -> bool: return True +def _is_heartbeat_ok_response(response: str | None) -> bool: + """Return True only for an exact HEARTBEAT_OK token (with simple markdown wrappers).""" + if not response: + return False + + normalized = re.sub(r"[\s`*_>\-]", "", response).upper() + return normalized == HEARTBEAT_OK_TOKEN.replace("_", "") + + class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks. @@ -75,6 +85,9 @@ class HeartbeatService: if not self.enabled: logger.info("Heartbeat disabled") return + if self._running: + logger.warning("Heartbeat already running") + return self._running = True self._task = asyncio.create_task(self._run_loop()) @@ -115,7 +128,7 @@ class HeartbeatService: response = await self.on_heartbeat(HEARTBEAT_PROMPT) # Check if agent said "nothing to do" - if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): + if _is_heartbeat_ok_response(response): logger.info("Heartbeat: OK (no action needed)") else: logger.info("Heartbeat: completed task") diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py new file mode 100644 index 0000000..52d1b96 --- /dev/null +++ b/tests/test_heartbeat_service.py @@ -0,0 +1,40 @@ +import asyncio + +import pytest + +from nanobot.heartbeat.service import ( + HeartbeatService, + _is_heartbeat_ok_response, +) + + +def test_heartbeat_ok_response_requires_exact_token() -> None: + assert _is_heartbeat_ok_response("HEARTBEAT_OK") + assert _is_heartbeat_ok_response("`HEARTBEAT_OK`") + assert _is_heartbeat_ok_response("**HEARTBEAT_OK**") + + assert not _is_heartbeat_ok_response("HEARTBEAT_OK, done") + assert not _is_heartbeat_ok_response("done HEARTBEAT_OK") + assert not _is_heartbeat_ok_response("HEARTBEAT_NOT_OK") + + +@pytest.mark.asyncio +async def test_start_is_idempotent(tmp_path) -> None: + async def _on_heartbeat(_: str) -> str: + return "HEARTBEAT_OK" + + service = HeartbeatService( + workspace=tmp_path, + on_heartbeat=_on_heartbeat, + interval_s=9999, + enabled=True, + ) + + await service.start() + first_task = service._task + await service.start() + + assert service._task is first_task + + service.stop() + await asyncio.sleep(0) From df2c837e252b76a8d3a91bd8a64b8987089f6892 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 07:12:41 +0000 Subject: [PATCH 05/13] feat(channels): split send_progress into send_progress + send_tool_hints --- nanobot/agent/loop.py | 12 +++++++----- nanobot/channels/manager.py | 7 +++++-- nanobot/cli/commands.py | 19 +++++++++++++++++-- nanobot/config/schema.py | 3 ++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 0bd05a8..cd67bdc 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -27,7 +27,7 @@ from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager if TYPE_CHECKING: - from nanobot.config.schema import ExecToolConfig + from nanobot.config.schema import ChannelsConfig, ExecToolConfig from nanobot.cron.service import CronService @@ -59,9 +59,11 @@ class AgentLoop: restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, + channels_config: ChannelsConfig | None = None, ): from nanobot.config.schema import ExecToolConfig self.bus = bus + self.channels_config = channels_config self.provider = provider self.workspace = workspace self.model = model or provider.get_default_model() @@ -172,7 +174,7 @@ class AgentLoop: async def _run_agent_loop( self, initial_messages: list[dict], - on_progress: Callable[[str], Awaitable[None]] | None = None, + on_progress: Callable[..., Awaitable[None]] | None = None, ) -> tuple[str | None, list[str]]: """Run the agent iteration loop. Returns (final_content, tools_used).""" messages = initial_messages @@ -196,8 +198,7 @@ class AgentLoop: clean = self._strip_think(response.content) if clean: await on_progress(clean) - else: - await on_progress(self._tool_hint(response.tool_calls)) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ { @@ -383,9 +384,10 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, ) - async def _bus_progress(content: str) -> None: + async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: meta = dict(msg.metadata or {}) meta["_progress"] = True + meta["_tool_hint"] = tool_hint await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 8a03883..77b7294 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -193,8 +193,11 @@ class ChannelManager: timeout=1.0 ) - if msg.metadata.get("_progress") and not self.config.channels.send_progress: - continue + if msg.metadata.get("_progress"): + if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + continue + if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + continue channel = self.channels.get(msg.channel) if channel: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f1f9b30..fcbd370 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -368,6 +368,7 @@ def gateway( restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) # Set cron callback (needs agent) @@ -484,6 +485,7 @@ def agent( cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) # Show spinner when logs are off (no output to miss); skip when logs are on @@ -494,7 +496,12 @@ def agent( # Animated spinner is safe to use with prompt_toolkit input handling return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") - async def _cli_progress(content: str) -> None: + async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: + ch = agent_loop.channels_config + if ch and tool_hint and not ch.send_tool_hints: + return + if ch and not tool_hint and not ch.send_progress: + return console.print(f" [dim]↳ {content}[/dim]") if message: @@ -535,7 +542,14 @@ def agent( try: msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) if msg.metadata.get("_progress"): - console.print(f" [dim]↳ {msg.content}[/dim]") + is_tool_hint = msg.metadata.get("_tool_hint", False) + ch = agent_loop.channels_config + if ch and is_tool_hint and not ch.send_tool_hints: + pass + elif ch and not is_tool_hint and not ch.send_progress: + pass + else: + console.print(f" [dim]↳ {msg.content}[/dim]") elif not turn_done.is_set(): if msg.content: turn_response.append(msg.content) @@ -961,6 +975,7 @@ def cron_run( exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) store_path = get_data_dir() / "cron" / "jobs.json" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fc9fede..9265602 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -168,7 +168,8 @@ class QQConfig(Base): class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = False + send_progress: bool = True # stream agent's text progress to the channel + send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) From 577b3d104a2f2e55e91e89ddbcbf154c760ece4c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 08:08:01 +0000 Subject: [PATCH 06/13] refactor: move workspace/ to nanobot/templates/ for packaging --- README.md | 20 +++ nanobot/cli/commands.py | 80 ++-------- nanobot/templates/AGENTS.md | 29 ++++ {workspace => nanobot/templates}/HEARTBEAT.md | 0 {workspace => nanobot/templates}/SOUL.md | 0 nanobot/templates/TOOLS.md | 36 +++++ {workspace => nanobot/templates}/USER.md | 0 nanobot/templates/__init__.py | 0 .../templates}/memory/MEMORY.md | 0 nanobot/templates/memory/__init__.py | 0 pyproject.toml | 3 +- workspace/AGENTS.md | 51 ------ workspace/TOOLS.md | 150 ------------------ 13 files changed, 102 insertions(+), 267 deletions(-) create mode 100644 nanobot/templates/AGENTS.md rename {workspace => nanobot/templates}/HEARTBEAT.md (100%) rename {workspace => nanobot/templates}/SOUL.md (100%) create mode 100644 nanobot/templates/TOOLS.md rename {workspace => nanobot/templates}/USER.md (100%) create mode 100644 nanobot/templates/__init__.py rename {workspace => nanobot/templates}/memory/MEMORY.md (100%) create mode 100644 nanobot/templates/memory/__init__.py delete mode 100644 workspace/AGENTS.md delete mode 100644 workspace/TOOLS.md diff --git a/README.md b/README.md index f20e21f..8c47f0f 100644 --- a/README.md +++ b/README.md @@ -841,6 +841,26 @@ nanobot cron remove +
+Heartbeat (Periodic Tasks) + +The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel. + +**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): + +```markdown +## Periodic Tasks + +- [ ] Check weather forecast and send a summary +- [ ] Scan inbox for urgent emails +``` + +The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. + +> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to. + +
+ ## 🐳 Docker > [!TIP] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c8948ee..5edebfa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -199,84 +199,34 @@ def onboard(): def _create_workspace_templates(workspace: Path): - """Create default workspace template files.""" - templates = { - "AGENTS.md": """# Agent Instructions + """Create default workspace template files from bundled templates.""" + from importlib.resources import files as pkg_files -You are a helpful AI assistant. Be concise, accurate, and friendly. + templates_dir = pkg_files("nanobot") / "templates" -## Guidelines + for item in templates_dir.iterdir(): + if not item.name.endswith(".md"): + continue + dest = workspace / item.name + if not dest.exists(): + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + console.print(f" [dim]Created {item.name}[/dim]") -- Always explain what you're doing before taking actions -- Ask for clarification when the request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in memory/MEMORY.md; past events are logged in memory/HISTORY.md -""", - "SOUL.md": """# Soul - -I am nanobot, a lightweight AI assistant. - -## Personality - -- Helpful and friendly -- Concise and to the point -- Curious and eager to learn - -## Values - -- Accuracy over speed -- User privacy and safety -- Transparency in actions -""", - "USER.md": """# User - -Information about the user goes here. - -## Preferences - -- Communication style: (casual/formal) -- Timezone: (your timezone) -- Language: (your preferred language) -""", - } - - for filename, content in templates.items(): - file_path = workspace / filename - if not file_path.exists(): - file_path.write_text(content, encoding="utf-8") - console.print(f" [dim]Created {filename}[/dim]") - - # Create memory directory and MEMORY.md memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) + + memory_template = templates_dir / "memory" / "MEMORY.md" memory_file = memory_dir / "MEMORY.md" if not memory_file.exists(): - memory_file.write_text("""# Long-term Memory - -This file stores important information that should persist across sessions. - -## User Information - -(Important facts about the user) - -## Preferences - -(User preferences learned over time) - -## Important Notes - -(Things to remember) -""", encoding="utf-8") + memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") console.print(" [dim]Created memory/MEMORY.md[/dim]") - + history_file = memory_dir / "HISTORY.md" if not history_file.exists(): history_file.write_text("", encoding="utf-8") console.print(" [dim]Created memory/HISTORY.md[/dim]") - # Create skills directory for custom user skills - skills_dir = workspace / "skills" - skills_dir.mkdir(exist_ok=True) + (workspace / "skills").mkdir(exist_ok=True) def _make_provider(config: Config): diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md new file mode 100644 index 0000000..155a0b2 --- /dev/null +++ b/nanobot/templates/AGENTS.md @@ -0,0 +1,29 @@ +# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Guidelines + +- Always explain what you're doing before taking actions +- Ask for clarification when the request is ambiguous +- Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md` + +## Scheduled Reminders + +When user asks for a reminder at a specific time, use `exec` to run: +``` +nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" +``` +Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). + +**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. + +## Heartbeat Tasks + +`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks: + +- **Add**: `edit_file` to append new tasks +- **Remove**: `edit_file` to delete completed tasks +- **Rewrite**: `write_file` to replace all tasks + +When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder. diff --git a/workspace/HEARTBEAT.md b/nanobot/templates/HEARTBEAT.md similarity index 100% rename from workspace/HEARTBEAT.md rename to nanobot/templates/HEARTBEAT.md diff --git a/workspace/SOUL.md b/nanobot/templates/SOUL.md similarity index 100% rename from workspace/SOUL.md rename to nanobot/templates/SOUL.md diff --git a/nanobot/templates/TOOLS.md b/nanobot/templates/TOOLS.md new file mode 100644 index 0000000..757edd2 --- /dev/null +++ b/nanobot/templates/TOOLS.md @@ -0,0 +1,36 @@ +# Tool Usage Notes + +Tool signatures are provided automatically via function calling. +This file documents non-obvious constraints and usage patterns. + +## exec — Safety Limits + +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) +- Output is truncated at 10,000 characters +- `restrictToWorkspace` config can limit file access to the workspace + +## Cron — Scheduled Reminders + +Use `exec` to create scheduled reminders: + +```bash +# Recurring: every day at 9am +nanobot cron add --name "morning" --message "Good morning!" --cron "0 9 * * *" + +# With timezone (--tz only works with --cron) +nanobot cron add --name "standup" --message "Standup time!" --cron "0 10 * * 1-5" --tz "Asia/Shanghai" + +# Recurring: every 2 hours +nanobot cron add --name "water" --message "Drink water!" --every 7200 + +# One-time: specific ISO time +nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00" + +# Deliver to a specific channel/user +nanobot cron add --name "reminder" --message "Check email" --at "2025-01-31T09:00:00" --deliver --to "USER_ID" --channel "CHANNEL" + +# Manage jobs +nanobot cron list +nanobot cron remove +``` diff --git a/workspace/USER.md b/nanobot/templates/USER.md similarity index 100% rename from workspace/USER.md rename to nanobot/templates/USER.md diff --git a/nanobot/templates/__init__.py b/nanobot/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workspace/memory/MEMORY.md b/nanobot/templates/memory/MEMORY.md similarity index 100% rename from workspace/memory/MEMORY.md rename to nanobot/templates/memory/MEMORY.md diff --git a/nanobot/templates/memory/__init__.py b/nanobot/templates/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index c337d02..cb58ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,10 +64,11 @@ packages = ["nanobot"] [tool.hatch.build.targets.wheel.sources] "nanobot" = "nanobot" -# Include non-Python files in skills +# Include non-Python files in skills and templates [tool.hatch.build] include = [ "nanobot/**/*.py", + "nanobot/templates/**/*.md", "nanobot/skills/**/*.md", "nanobot/skills/**/*.sh", ] diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md deleted file mode 100644 index 69bd823..0000000 --- a/workspace/AGENTS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. - -## Guidelines - -- Always explain what you're doing before taking actions -- Ask for clarification when the request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in your memory files - -## Tools Available - -You have access to: -- File operations (read, write, edit, list) -- Shell commands (exec) -- Web access (search, fetch) -- Messaging (message) -- Background tasks (spawn) - -## Memory - -- `memory/MEMORY.md` — long-term facts (preferences, context, relationships) -- `memory/HISTORY.md` — append-only event log, search with grep to recall past events - -## Scheduled Reminders - -When user asks for a reminder at a specific time, use `exec` to run: -``` -nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" -``` -Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). - -**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. - -## Heartbeat Tasks - -`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file: - -- **Add a task**: Use `edit_file` to append new tasks to `HEARTBEAT.md` -- **Remove a task**: Use `edit_file` to remove completed or obsolete tasks -- **Rewrite tasks**: Use `write_file` to completely rewrite the task list - -Task format examples: -``` -- [ ] Check calendar and remind of upcoming events -- [ ] Scan inbox for urgent emails -- [ ] Check weather forecast for today -``` - -When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage. diff --git a/workspace/TOOLS.md b/workspace/TOOLS.md deleted file mode 100644 index 0134a64..0000000 --- a/workspace/TOOLS.md +++ /dev/null @@ -1,150 +0,0 @@ -# Available Tools - -This document describes the tools available to nanobot. - -## File Operations - -### read_file -Read the contents of a file. -``` -read_file(path: str) -> str -``` - -### write_file -Write content to a file (creates parent directories if needed). -``` -write_file(path: str, content: str) -> str -``` - -### edit_file -Edit a file by replacing specific text. -``` -edit_file(path: str, old_text: str, new_text: str) -> str -``` - -### list_dir -List contents of a directory. -``` -list_dir(path: str) -> str -``` - -## Shell Execution - -### exec -Execute a shell command and return output. -``` -exec(command: str, working_dir: str = None) -> str -``` - -**Safety Notes:** -- Commands have a configurable timeout (default 60s) -- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) -- Output is truncated at 10,000 characters -- Optional `restrictToWorkspace` config to limit paths - -## Web Access - -### web_search -Search the web using Brave Search API. -``` -web_search(query: str, count: int = 5) -> str -``` - -Returns search results with titles, URLs, and snippets. Requires `tools.web.search.apiKey` in config. - -### web_fetch -Fetch and extract main content from a URL. -``` -web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str -``` - -**Notes:** -- Content is extracted using readability -- Supports markdown or plain text extraction -- Output is truncated at 50,000 characters by default - -## Communication - -### message -Send a message to the user (used internally). -``` -message(content: str, channel: str = None, chat_id: str = None) -> str -``` - -## Background Tasks - -### spawn -Spawn a subagent to handle a task in the background. -``` -spawn(task: str, label: str = None) -> str -``` - -Use for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. - -## Scheduled Reminders (Cron) - -Use the `exec` tool to create scheduled reminders with `nanobot cron add`: - -### Set a recurring reminder -```bash -# Every day at 9am -nanobot cron add --name "morning" --message "Good morning! ☀️" --cron "0 9 * * *" - -# Every 2 hours -nanobot cron add --name "water" --message "Drink water! 💧" --every 7200 -``` - -### Set a one-time reminder -```bash -# At a specific time (ISO format) -nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00" -``` - -### Manage reminders -```bash -nanobot cron list # List all jobs -nanobot cron remove # Remove a job -``` - -## Heartbeat Task Management - -The `HEARTBEAT.md` file in the workspace is checked every 30 minutes. -Use file operations to manage periodic tasks: - -### Add a heartbeat task -```python -# Append a new task -edit_file( - path="HEARTBEAT.md", - old_text="## Example Tasks", - new_text="- [ ] New periodic task here\n\n## Example Tasks" -) -``` - -### Remove a heartbeat task -```python -# Remove a specific task -edit_file( - path="HEARTBEAT.md", - old_text="- [ ] Task to remove\n", - new_text="" -) -``` - -### Rewrite all tasks -```python -# Replace the entire file -write_file( - path="HEARTBEAT.md", - content="# Heartbeat Tasks\n\n- [ ] Task 1\n- [ ] Task 2\n" -) -``` - ---- - -## Adding Custom Tools - -To add custom tools: -1. Create a class that extends `Tool` in `nanobot/agent/tools/` -2. Implement `name`, `description`, `parameters`, and `execute` -3. Register it in `AgentLoop._register_default_tools()` From 491739223d8a5e8bfea0fc040971dffb8a6f3d0f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 08:24:53 +0000 Subject: [PATCH 07/13] fix: lower default temperature from 0.7 to 0.1 --- nanobot/agent/loop.py | 2 +- nanobot/config/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cd67bdc..296c908 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -50,7 +50,7 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, - temperature: float = 0.7, + temperature: float = 0.1, max_tokens: int = 4096, memory_window: int = 50, brave_api_key: str | None = None, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9265602..10e3fa5 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -187,7 +187,7 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 - temperature: float = 0.7 + temperature: float = 0.1 max_tool_iterations: int = 20 memory_window: int = 50 From d9462284e1549f86570874096e2e4ea343a7bc17 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 09:13:08 +0000 Subject: [PATCH 08/13] improve agent reliability: behavioral constraints, full tool history, error hints --- README.md | 2 +- nanobot/agent/context.py | 18 +++++++----- nanobot/agent/loop.py | 49 +++++++++++++++++++++++---------- nanobot/agent/tools/registry.py | 13 ++++++--- nanobot/config/schema.py | 4 +-- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8c47f0f..148c8f4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,862 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,897 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index c5869f3..98c13f2 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -96,14 +96,18 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md -IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. -Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). -If you need to use tools, call them directly — never send a preliminary message like "Let me check" without actually calling a tool. -When remembering something important, write to {workspace_path}/memory/MEMORY.md -To recall past events, grep {workspace_path}/memory/HISTORY.md""" +## Tool Call Guidelines +- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it. +- Before modifying a file, read it first to confirm its current content. +- Do not assume a file or directory exists — use list_dir or read_file to verify. +- After writing or editing a file, re-read it if accuracy matters. +- If a tool call fails, analyze the error before retrying with a different approach. + +## Memory +- Remember important facts: write to {workspace_path}/memory/MEMORY.md +- Recall past events: grep {workspace_path}/memory/HISTORY.md""" def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 296c908..8be8e51 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -49,10 +49,10 @@ class AgentLoop: provider: LLMProvider, workspace: Path, model: str | None = None, - max_iterations: int = 20, + max_iterations: int = 40, temperature: float = 0.1, max_tokens: int = 4096, - memory_window: int = 50, + memory_window: int = 100, brave_api_key: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, @@ -175,8 +175,8 @@ class AgentLoop: self, initial_messages: list[dict], on_progress: Callable[..., Awaitable[None]] | None = None, - ) -> tuple[str | None, list[str]]: - """Run the agent iteration loop. Returns (final_content, tools_used).""" + ) -> tuple[str | None, list[str], list[dict]]: + """Run the agent iteration loop. Returns (final_content, tools_used, messages).""" messages = initial_messages iteration = 0 final_content = None @@ -228,7 +228,14 @@ class AgentLoop: final_content = self._strip_think(response.content) break - return final_content, tools_used + if final_content is None and iteration >= self.max_iterations: + logger.warning("Max iterations ({}) reached", self.max_iterations) + final_content = ( + f"I reached the maximum number of tool call iterations ({self.max_iterations}) " + "without completing the task. You can try breaking the task into smaller steps." + ) + + return final_content, tools_used, messages async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" @@ -301,13 +308,13 @@ class AgentLoop: key = f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) + history = session.get_history(max_messages=self.memory_window) messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), + history=history, current_message=msg.content, channel=channel, chat_id=chat_id, ) - final_content, _ = await self._run_agent_loop(messages) - session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") - session.add_message("assistant", final_content or "Background task completed.") + final_content, _, all_msgs = await self._run_agent_loop(messages) + self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) return OutboundMessage(channel=channel, chat_id=chat_id, content=final_content or "Background task completed.") @@ -377,8 +384,9 @@ class AgentLoop: if isinstance(message_tool, MessageTool): message_tool.start_turn() + history = session.get_history(max_messages=self.memory_window) initial_messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), + history=history, current_message=msg.content, media=msg.media if msg.media else None, channel=msg.channel, chat_id=msg.chat_id, @@ -392,7 +400,7 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) - final_content, tools_used = await self._run_agent_loop( + final_content, _, all_msgs = await self._run_agent_loop( initial_messages, on_progress=on_progress or _bus_progress, ) @@ -402,9 +410,7 @@ class AgentLoop: preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - session.add_message("user", msg.content) - session.add_message("assistant", final_content, - tools_used=tools_used if tools_used else None) + self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) if message_tool := self.tools.get("message"): @@ -416,6 +422,21 @@ class AgentLoop: metadata=msg.metadata or {}, ) + _TOOL_RESULT_MAX_CHARS = 500 + + def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: + """Save new-turn messages into session, truncating large tool results.""" + from datetime import datetime + for m in messages[skip:]: + entry = {k: v for k, v in m.items() if k != "reasoning_content"} + if entry.get("role") == "tool" and isinstance(entry.get("content"), str): + content = entry["content"] + if len(content) > self._TOOL_RESULT_MAX_CHARS: + entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + entry.setdefault("timestamp", datetime.now().isoformat()) + session.messages.append(entry) + session.updated_at = datetime.now() + async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: """Delegate to MemoryStore.consolidate(). Returns True on success.""" return await MemoryStore(self.workspace).consolidate( diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index d9b33ff..8256a59 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -49,17 +49,22 @@ class ToolRegistry: Raises: KeyError: If tool not found. """ + _HINT = "\n\n[Analyze the error above and try a different approach.]" + tool = self._tools.get(name) if not tool: - return f"Error: Tool '{name}' not found" + return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" try: errors = tool.validate_params(params) if errors: - return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) - return await tool.execute(**params) + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT + result = await tool.execute(**params) + if isinstance(result, str) and result.startswith("Error"): + return result + _HINT + return result except Exception as e: - return f"Error executing {name}: {str(e)}" + return f"Error executing {name}: {str(e)}" + _HINT @property def tool_names(self) -> list[str]: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 10e3fa5..fe8dd83 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -188,8 +188,8 @@ class AgentDefaults(Base): model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 temperature: float = 0.1 - max_tool_iterations: int = 20 - memory_window: int = 50 + max_tool_iterations: int = 40 + memory_window: int = 100 class AgentsConfig(Base): From 1f7a81e5eebafad5e21c5760a92880c3155bcefe Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 23 Feb 2026 10:15:12 +0000 Subject: [PATCH 09/13] feat(slack): isolate session context per thread Each Slack thread now gets its own conversation session instead of sharing one session per channel. DM sessions are unchanged. Added as a generic feature to also support if Feishu threads support is added in the future. --- nanobot/bus/events.py | 3 ++- nanobot/channels/base.py | 4 +++- nanobot/channels/slack.py | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index a149e20..a48660d 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -16,11 +16,12 @@ class InboundMessage: timestamp: datetime = field(default_factory=datetime.now) media: list[str] = field(default_factory=list) # Media URLs metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data + session_key_override: str | None = None # Optional override for thread-scoped sessions @property def session_key(self) -> str: """Unique key for session identification.""" - return f"{self.channel}:{self.chat_id}" + return self.session_key_override or f"{self.channel}:{self.chat_id}" @dataclass diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 3a5a785..2201686 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -111,13 +111,15 @@ class BaseChannel(ABC): ) return + meta = metadata or {} msg = InboundMessage( channel=self.name, sender_id=str(sender_id), chat_id=str(chat_id), content=content, media=media or [], - metadata=metadata or {} + metadata=meta, + session_key_override=meta.get("session_key"), ) await self.bus.publish_inbound(msg) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index b0f9bbb..2e91f7b 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -179,6 +179,9 @@ class SlackChannel(BaseChannel): except Exception as e: logger.debug("Slack reactions_add failed: {}", e) + # Thread-scoped session key for channel/group messages + session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None + try: await self._handle_message( sender_id=sender_id, @@ -189,7 +192,8 @@ class SlackChannel(BaseChannel): "event": event, "thread_ts": thread_ts, "channel_type": channel_type, - } + }, + "session_key": session_key, }, ) except Exception: From ea1c4ef02566a94d9d2c9593698a626365e183de Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 12:33:29 +0000 Subject: [PATCH 10/13] fix: suppress heartbeat progress messages to external channels --- nanobot/cli/commands.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5edebfa..e1df6ad 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -363,11 +363,17 @@ def gateway( async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" channel, chat_id = _pick_heartbeat_target() + + async def _silent(*_args, **_kwargs): + pass + return await agent.process_direct( prompt, session_key="heartbeat", channel=channel, chat_id=chat_id, + # suppress: heartbeat should not push progress to external channels + on_progress=_silent, ) heartbeat = HeartbeatService( From 2b983c708dc0f99ad8402b1eaafdf2b14894eeeb Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:10:47 +0000 Subject: [PATCH 11/13] refactor: pass session_key as explicit param instead of via metadata --- nanobot/channels/base.py | 9 +++++---- nanobot/channels/slack.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 2201686..3010373 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -89,7 +89,8 @@ class BaseChannel(ABC): chat_id: str, content: str, media: list[str] | None = None, - metadata: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None, + session_key: str | None = None, ) -> None: """ Handle an incoming message from the chat platform. @@ -102,6 +103,7 @@ class BaseChannel(ABC): content: Message text content. media: Optional list of media URLs. metadata: Optional channel-specific metadata. + session_key: Optional session key override (e.g. thread-scoped sessions). """ if not self.is_allowed(sender_id): logger.warning( @@ -111,15 +113,14 @@ class BaseChannel(ABC): ) return - meta = metadata or {} msg = InboundMessage( channel=self.name, sender_id=str(sender_id), chat_id=str(chat_id), content=content, media=media or [], - metadata=meta, - session_key_override=meta.get("session_key"), + metadata=metadata or {}, + session_key_override=session_key, ) await self.bus.publish_inbound(msg) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 2e91f7b..906593b 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -193,8 +193,8 @@ class SlackChannel(BaseChannel): "thread_ts": thread_ts, "channel_type": channel_type, }, - "session_key": session_key, }, + session_key=session_key, ) except Exception: logger.exception("Error handling Slack message from {}", sender_id) From 7671239902f479c8c0b60aac53454e6ef8f28146 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:45:09 +0000 Subject: [PATCH 12/13] fix(heartbeat): suppress progress messages and deliver agent response to user --- nanobot/cli/commands.py | 12 +++++++++-- nanobot/heartbeat/service.py | 40 ++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e1df6ad..90b9f44 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -372,13 +372,21 @@ def gateway( session_key="heartbeat", channel=channel, chat_id=chat_id, - # suppress: heartbeat should not push progress to external channels - on_progress=_silent, + on_progress=_silent, # suppress: heartbeat should not push progress to external channels ) + async def on_heartbeat_notify(response: str) -> None: + """Deliver a heartbeat response to the user's channel.""" + from nanobot.bus.events import OutboundMessage + channel, chat_id = _pick_heartbeat_target() + if channel == "cli": + return # No external channel available to deliver to + await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, + on_notify=on_heartbeat_notify, interval_s=30 * 60, # 30 minutes enabled=True ) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3c1a6aa..7dbdc03 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -9,14 +9,15 @@ from loguru import logger # Default interval: 30 minutes DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 -# The prompt sent to agent during heartbeat -HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). -Follow any instructions or tasks listed there. -If nothing needs attention, reply with just: HEARTBEAT_OK""" - -# Token that indicates "nothing to do" +# Token the agent replies with when there is nothing to report HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" +# The prompt sent to agent during heartbeat +HEARTBEAT_PROMPT = ( + "Read HEARTBEAT.md in your workspace and follow any instructions listed there. " + f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" +) + def _is_heartbeat_empty(content: str | None) -> bool: """Check if HEARTBEAT.md has no actionable content.""" @@ -38,20 +39,24 @@ def _is_heartbeat_empty(content: str | None) -> bool: 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. + + The agent reads HEARTBEAT.md from the workspace and executes any tasks + listed there. If it has something to report, the response is forwarded + to the user via on_notify. If nothing needs attention, the agent replies + HEARTBEAT_OK and the response is silently dropped. """ - + def __init__( self, workspace: Path, on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, + on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None, interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S, enabled: bool = True, ): self.workspace = workspace self.on_heartbeat = on_heartbeat + self.on_notify = on_notify self.interval_s = interval_s self.enabled = enabled self._running = False @@ -113,15 +118,14 @@ class HeartbeatService: if self.on_heartbeat: try: 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)") + if HEARTBEAT_OK_TOKEN in response.upper(): + logger.info("Heartbeat: OK (nothing to report)") else: - logger.info("Heartbeat: completed task") - - except Exception as e: - logger.error("Heartbeat execution failed: {}", e) + logger.info("Heartbeat: completed, delivering response") + if self.on_notify: + await self.on_notify(response) + except Exception: + logger.exception("Heartbeat execution failed") async def trigger_now(self) -> str | None: """Manually trigger a heartbeat.""" From eae6059889bcb8000ab8f5dc8fdbe4b9c8816180 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:59:47 +0000 Subject: [PATCH 13/13] fix: remove extra blank line --- nanobot/heartbeat/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3e40f2f..cb1a1c7 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -36,7 +36,6 @@ def _is_heartbeat_empty(content: str | None) -> bool: return True - class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks.