Merge remote-tracking branch 'origin/main' into pr-1029
This commit is contained in:
22
README.md
22
README.md
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
⚡️ 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
|
## 📢 News
|
||||||
|
|
||||||
@@ -841,6 +841,26 @@ nanobot cron remove <job_id>
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|||||||
@@ -96,14 +96,18 @@ Your workspace is at: {workspace_path}
|
|||||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||||
|
|
||||||
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
|
||||||
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.
|
|
||||||
|
|
||||||
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).
|
## Tool Call Guidelines
|
||||||
If you need to use tools, call them directly — never send a preliminary message like "Let me check" without actually calling a tool.
|
- 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.
|
||||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
- Before modifying a file, read it first to confirm its current content.
|
||||||
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
- 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:
|
def _load_bootstrap_files(self) -> str:
|
||||||
"""Load all bootstrap files from workspace."""
|
"""Load all bootstrap files from workspace."""
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from nanobot.providers.base import LLMProvider
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import ExecToolConfig
|
from nanobot.config.schema import ChannelsConfig, ExecToolConfig
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
|
|
||||||
@@ -49,19 +49,21 @@ class AgentLoop:
|
|||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_iterations: int = 20,
|
max_iterations: int = 40,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.1,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
memory_window: int = 50,
|
memory_window: int = 100,
|
||||||
brave_api_key: str | None = None,
|
brave_api_key: str | None = None,
|
||||||
exec_config: ExecToolConfig | None = None,
|
exec_config: ExecToolConfig | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
mcp_servers: dict | None = None,
|
mcp_servers: dict | None = None,
|
||||||
|
channels_config: ChannelsConfig | None = None,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ExecToolConfig
|
from nanobot.config.schema import ExecToolConfig
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
self.channels_config = channels_config
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.model = model or provider.get_default_model()
|
self.model = model or provider.get_default_model()
|
||||||
@@ -172,9 +174,9 @@ class AgentLoop:
|
|||||||
async def _run_agent_loop(
|
async def _run_agent_loop(
|
||||||
self,
|
self,
|
||||||
initial_messages: list[dict],
|
initial_messages: list[dict],
|
||||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||||
) -> tuple[str | None, list[str]]:
|
) -> tuple[str | None, list[str], list[dict]]:
|
||||||
"""Run the agent iteration loop. Returns (final_content, tools_used)."""
|
"""Run the agent iteration loop. Returns (final_content, tools_used, messages)."""
|
||||||
messages = initial_messages
|
messages = initial_messages
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
@@ -196,8 +198,7 @@ class AgentLoop:
|
|||||||
clean = self._strip_think(response.content)
|
clean = self._strip_think(response.content)
|
||||||
if clean:
|
if clean:
|
||||||
await on_progress(clean)
|
await on_progress(clean)
|
||||||
else:
|
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
|
||||||
await on_progress(self._tool_hint(response.tool_calls))
|
|
||||||
|
|
||||||
tool_call_dicts = [
|
tool_call_dicts = [
|
||||||
{
|
{
|
||||||
@@ -227,7 +228,14 @@ class AgentLoop:
|
|||||||
final_content = self._strip_think(response.content)
|
final_content = self._strip_think(response.content)
|
||||||
break
|
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:
|
async def run(self) -> None:
|
||||||
"""Run the agent loop, processing messages from the bus."""
|
"""Run the agent loop, processing messages from the bus."""
|
||||||
@@ -300,13 +308,13 @@ class AgentLoop:
|
|||||||
key = f"{channel}:{chat_id}"
|
key = f"{channel}:{chat_id}"
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
|
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(
|
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,
|
current_message=msg.content, channel=channel, chat_id=chat_id,
|
||||||
)
|
)
|
||||||
final_content, _ = await self._run_agent_loop(messages)
|
final_content, _, all_msgs = await self._run_agent_loop(messages)
|
||||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
self._save_turn(session, all_msgs, 1 + len(history))
|
||||||
session.add_message("assistant", final_content or "Background task completed.")
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return OutboundMessage(channel=channel, chat_id=chat_id,
|
return OutboundMessage(channel=channel, chat_id=chat_id,
|
||||||
content=final_content or "Background task completed.")
|
content=final_content or "Background task completed.")
|
||||||
@@ -352,7 +360,8 @@ class AgentLoop:
|
|||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
|
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)
|
self._consolidating.add(session.key)
|
||||||
lock = self._get_consolidation_lock(session.key)
|
lock = self._get_consolidation_lock(session.key)
|
||||||
|
|
||||||
@@ -375,21 +384,23 @@ class AgentLoop:
|
|||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.start_turn()
|
message_tool.start_turn()
|
||||||
|
|
||||||
|
history = session.get_history(max_messages=self.memory_window)
|
||||||
initial_messages = self.context.build_messages(
|
initial_messages = self.context.build_messages(
|
||||||
history=session.get_history(max_messages=self.memory_window),
|
history=history,
|
||||||
current_message=msg.content,
|
current_message=msg.content,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
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 = dict(msg.metadata or {})
|
||||||
meta["_progress"] = True
|
meta["_progress"] = True
|
||||||
|
meta["_tool_hint"] = tool_hint
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
|
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,
|
initial_messages, on_progress=on_progress or _bus_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -399,9 +410,7 @@ class AgentLoop:
|
|||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
|
|
||||||
session.add_message("user", msg.content)
|
self._save_turn(session, all_msgs, 1 + len(history))
|
||||||
session.add_message("assistant", final_content,
|
|
||||||
tools_used=tools_used if tools_used else None)
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
if message_tool := self.tools.get("message"):
|
if message_tool := self.tools.get("message"):
|
||||||
@@ -413,6 +422,21 @@ class AgentLoop:
|
|||||||
metadata=msg.metadata or {},
|
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:
|
async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
|
||||||
"""Delegate to MemoryStore.consolidate(). Returns True on success."""
|
"""Delegate to MemoryStore.consolidate(). Returns True on success."""
|
||||||
return await MemoryStore(self.workspace).consolidate(
|
return await MemoryStore(self.workspace).consolidate(
|
||||||
|
|||||||
@@ -49,17 +49,22 @@ class ToolRegistry:
|
|||||||
Raises:
|
Raises:
|
||||||
KeyError: If tool not found.
|
KeyError: If tool not found.
|
||||||
"""
|
"""
|
||||||
|
_HINT = "\n\n[Analyze the error above and try a different approach.]"
|
||||||
|
|
||||||
tool = self._tools.get(name)
|
tool = self._tools.get(name)
|
||||||
if not tool:
|
if not tool:
|
||||||
return f"Error: Tool '{name}' not found"
|
return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
errors = tool.validate_params(params)
|
errors = tool.validate_params(params)
|
||||||
if errors:
|
if errors:
|
||||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT
|
||||||
return await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
|
return result + _HINT
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error executing {name}: {str(e)}"
|
return f"Error executing {name}: {str(e)}" + _HINT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ class InboundMessage:
|
|||||||
timestamp: datetime = field(default_factory=datetime.now)
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
media: list[str] = field(default_factory=list) # Media URLs
|
media: list[str] = field(default_factory=list) # Media URLs
|
||||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||||
|
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_key(self) -> str:
|
def session_key(self) -> str:
|
||||||
"""Unique key for session identification."""
|
"""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
|
@dataclass
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ class BaseChannel(ABC):
|
|||||||
chat_id: str,
|
chat_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
media: list[str] | None = None,
|
media: list[str] | None = None,
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Handle an incoming message from the chat platform.
|
Handle an incoming message from the chat platform.
|
||||||
@@ -102,6 +103,7 @@ class BaseChannel(ABC):
|
|||||||
content: Message text content.
|
content: Message text content.
|
||||||
media: Optional list of media URLs.
|
media: Optional list of media URLs.
|
||||||
metadata: Optional channel-specific metadata.
|
metadata: Optional channel-specific metadata.
|
||||||
|
session_key: Optional session key override (e.g. thread-scoped sessions).
|
||||||
"""
|
"""
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -117,7 +119,8 @@ class BaseChannel(ABC):
|
|||||||
chat_id=str(chat_id),
|
chat_id=str(chat_id),
|
||||||
content=content,
|
content=content,
|
||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=metadata or {}
|
metadata=metadata or {},
|
||||||
|
session_key_override=session_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ class ChannelManager:
|
|||||||
timeout=1.0
|
timeout=1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ class SlackChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Slack reactions_add failed: {}", 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:
|
try:
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
@@ -189,8 +192,9 @@ class SlackChannel(BaseChannel):
|
|||||||
"event": event,
|
"event": event,
|
||||||
"thread_ts": thread_ts,
|
"thread_ts": thread_ts,
|
||||||
"channel_type": channel_type,
|
"channel_type": channel_type,
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling Slack message from {}", sender_id)
|
logger.exception("Error handling Slack message from {}", sender_id)
|
||||||
|
|||||||
@@ -199,74 +199,26 @@ def onboard():
|
|||||||
|
|
||||||
|
|
||||||
def _create_workspace_templates(workspace: Path):
|
def _create_workspace_templates(workspace: Path):
|
||||||
"""Create default workspace template files."""
|
"""Create default workspace template files from bundled templates."""
|
||||||
templates = {
|
from importlib.resources import files as pkg_files
|
||||||
"AGENTS.md": """# Agent Instructions
|
|
||||||
|
|
||||||
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 = workspace / "memory"
|
||||||
memory_dir.mkdir(exist_ok=True)
|
memory_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
memory_template = templates_dir / "memory" / "MEMORY.md"
|
||||||
memory_file = memory_dir / "MEMORY.md"
|
memory_file = memory_dir / "MEMORY.md"
|
||||||
if not memory_file.exists():
|
if not memory_file.exists():
|
||||||
memory_file.write_text("""# Long-term Memory
|
memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
|
||||||
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")
|
|
||||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||||
|
|
||||||
history_file = memory_dir / "HISTORY.md"
|
history_file = memory_dir / "HISTORY.md"
|
||||||
@@ -274,9 +226,7 @@ This file stores important information that should persist across sessions.
|
|||||||
history_file.write_text("", encoding="utf-8")
|
history_file.write_text("", encoding="utf-8")
|
||||||
console.print(" [dim]Created memory/HISTORY.md[/dim]")
|
console.print(" [dim]Created memory/HISTORY.md[/dim]")
|
||||||
|
|
||||||
# Create skills directory for custom user skills
|
(workspace / "skills").mkdir(exist_ok=True)
|
||||||
skills_dir = workspace / "skills"
|
|
||||||
skills_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
def _make_provider(config: Config):
|
||||||
@@ -368,6 +318,7 @@ def gateway(
|
|||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=config.tools.mcp_servers,
|
||||||
|
channels_config=config.channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
@@ -389,21 +340,57 @@ def gateway(
|
|||||||
return response
|
return response
|
||||||
cron.on_job = on_cron_job
|
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
|
# Create heartbeat service
|
||||||
async def on_heartbeat(prompt: str) -> str:
|
async def on_heartbeat(prompt: str) -> str:
|
||||||
"""Execute heartbeat through the agent."""
|
"""Execute heartbeat through the agent."""
|
||||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
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,
|
||||||
|
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(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
on_heartbeat=on_heartbeat,
|
on_heartbeat=on_heartbeat,
|
||||||
|
on_notify=on_heartbeat_notify,
|
||||||
interval_s=30 * 60, # 30 minutes
|
interval_s=30 * 60, # 30 minutes
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create channel manager
|
|
||||||
channels = ChannelManager(config, bus)
|
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
else:
|
else:
|
||||||
@@ -484,6 +471,7 @@ def agent(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
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
|
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||||
@@ -494,7 +482,12 @@ def agent(
|
|||||||
# Animated spinner is safe to use with prompt_toolkit input handling
|
# Animated spinner is safe to use with prompt_toolkit input handling
|
||||||
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
|
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]")
|
console.print(f" [dim]↳ {content}[/dim]")
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
@@ -535,6 +528,13 @@ def agent(
|
|||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||||
if msg.metadata.get("_progress"):
|
if msg.metadata.get("_progress"):
|
||||||
|
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]")
|
console.print(f" [dim]↳ {msg.content}[/dim]")
|
||||||
elif not turn_done.is_set():
|
elif not turn_done.is_set():
|
||||||
if msg.content:
|
if msg.content:
|
||||||
@@ -961,6 +961,7 @@ def cron_run(
|
|||||||
exec_config=config.tools.exec,
|
exec_config=config.tools.exec,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=config.tools.mcp_servers,
|
||||||
|
channels_config=config.channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ class QQConfig(Base):
|
|||||||
class ChannelsConfig(Base):
|
class ChannelsConfig(Base):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
|
|
||||||
|
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)
|
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||||
@@ -185,9 +187,9 @@ class AgentDefaults(Base):
|
|||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
model: str = "anthropic/claude-opus-4-5"
|
model: str = "anthropic/claude-opus-4-5"
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
temperature: float = 0.7
|
temperature: float = 0.1
|
||||||
max_tool_iterations: int = 20
|
max_tool_iterations: int = 40
|
||||||
memory_window: int = 50
|
memory_window: int = 100
|
||||||
|
|
||||||
|
|
||||||
class AgentsConfig(Base):
|
class AgentsConfig(Base):
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ from loguru import logger
|
|||||||
# Default interval: 30 minutes
|
# Default interval: 30 minutes
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
||||||
|
|
||||||
# The prompt sent to agent during heartbeat
|
# Token the agent replies with when there is nothing to report
|
||||||
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"
|
|
||||||
HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"
|
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:
|
def _is_heartbeat_empty(content: str | None) -> bool:
|
||||||
"""Check if HEARTBEAT.md has no actionable content."""
|
"""Check if HEARTBEAT.md has no actionable content."""
|
||||||
@@ -39,19 +40,23 @@ class HeartbeatService:
|
|||||||
"""
|
"""
|
||||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
Periodic heartbeat service that wakes the agent to check for tasks.
|
||||||
|
|
||||||
The agent reads HEARTBEAT.md from the workspace and executes any
|
The agent reads HEARTBEAT.md from the workspace and executes any tasks
|
||||||
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
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,
|
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.on_heartbeat = on_heartbeat
|
self.on_heartbeat = on_heartbeat
|
||||||
|
self.on_notify = on_notify
|
||||||
self.interval_s = interval_s
|
self.interval_s = interval_s
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -75,6 +80,9 @@ class HeartbeatService:
|
|||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
logger.info("Heartbeat disabled")
|
logger.info("Heartbeat disabled")
|
||||||
return
|
return
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Heartbeat already running")
|
||||||
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._task = asyncio.create_task(self._run_loop())
|
self._task = asyncio.create_task(self._run_loop())
|
||||||
@@ -113,15 +121,14 @@ class HeartbeatService:
|
|||||||
if self.on_heartbeat:
|
if self.on_heartbeat:
|
||||||
try:
|
try:
|
||||||
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
||||||
|
if HEARTBEAT_OK_TOKEN in response.upper():
|
||||||
# Check if agent said "nothing to do"
|
logger.info("Heartbeat: OK (nothing to report)")
|
||||||
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
|
|
||||||
logger.info("Heartbeat: OK (no action needed)")
|
|
||||||
else:
|
else:
|
||||||
logger.info("Heartbeat: completed task")
|
logger.info("Heartbeat: completed, delivering response")
|
||||||
|
if self.on_notify:
|
||||||
except Exception as e:
|
await self.on_notify(response)
|
||||||
logger.error("Heartbeat execution failed: {}", e)
|
except Exception:
|
||||||
|
logger.exception("Heartbeat execution failed")
|
||||||
|
|
||||||
async def trigger_now(self) -> str | None:
|
async def trigger_now(self) -> str | None:
|
||||||
"""Manually trigger a heartbeat."""
|
"""Manually trigger a heartbeat."""
|
||||||
|
|||||||
29
nanobot/templates/AGENTS.md
Normal file
29
nanobot/templates/AGENTS.md
Normal file
@@ -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.
|
||||||
36
nanobot/templates/TOOLS.md
Normal file
36
nanobot/templates/TOOLS.md
Normal file
@@ -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 <job_id>
|
||||||
|
```
|
||||||
0
nanobot/templates/__init__.py
Normal file
0
nanobot/templates/__init__.py
Normal file
0
nanobot/templates/memory/__init__.py
Normal file
0
nanobot/templates/memory/__init__.py
Normal file
@@ -64,10 +64,11 @@ packages = ["nanobot"]
|
|||||||
[tool.hatch.build.targets.wheel.sources]
|
[tool.hatch.build.targets.wheel.sources]
|
||||||
"nanobot" = "nanobot"
|
"nanobot" = "nanobot"
|
||||||
|
|
||||||
# Include non-Python files in skills
|
# Include non-Python files in skills and templates
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/**/*.py",
|
"nanobot/**/*.py",
|
||||||
|
"nanobot/templates/**/*.md",
|
||||||
"nanobot/skills/**/*.md",
|
"nanobot/skills/**/*.md",
|
||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
]
|
]
|
||||||
|
|||||||
44
tests/test_heartbeat_service.py
Normal file
44
tests/test_heartbeat_service.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.heartbeat.service import (
|
||||||
|
HEARTBEAT_OK_TOKEN,
|
||||||
|
HeartbeatService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_ok_detection() -> None:
|
||||||
|
def is_ok(response: str) -> bool:
|
||||||
|
return HEARTBEAT_OK_TOKEN in response.upper()
|
||||||
|
|
||||||
|
assert is_ok("HEARTBEAT_OK")
|
||||||
|
assert is_ok("`HEARTBEAT_OK`")
|
||||||
|
assert is_ok("**HEARTBEAT_OK**")
|
||||||
|
assert is_ok("heartbeat_ok")
|
||||||
|
assert is_ok("HEARTBEAT_OK.")
|
||||||
|
|
||||||
|
assert not is_ok("HEARTBEAT_NOT_OK")
|
||||||
|
assert not is_ok("all good")
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -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.
|
|
||||||
@@ -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 <job_id> # 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()`
|
|
||||||
Reference in New Issue
Block a user