Merge branch 'main' into main

This commit is contained in:
Dontrail Cotlage 2026-02-05 18:35:19 -05:00 committed by GitHub
commit 6df2905c04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 214 additions and 15 deletions

View File

@ -18,12 +18,18 @@
## 📢 News ## 📢 News
<<<<<<< main
- **2025-02-03** 🔒 Security audit completed! See [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) and [SECURITY.md](./SECURITY.md) for details. - **2025-02-03** 🔒 Security audit completed! See [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) and [SECURITY.md](./SECURITY.md) for details.
- **2025-02-01** 🎉 nanobot launched! Welcome to try 🐈 nanobot! - **2025-02-01** 🎉 nanobot launched! Welcome to try 🐈 nanobot!
> [!IMPORTANT] > [!IMPORTANT]
> **Security Notice**: If you're using nanobot in production, please review [SECURITY.md](./SECURITY.md) for security best practices. > **Security Notice**: If you're using nanobot in production, please review [SECURITY.md](./SECURITY.md) for security best practices.
> Key actions: Configure `allowFrom` lists, secure your API keys, and keep dependencies updated. > Key actions: Configure `allowFrom` lists, secure your API keys, and keep dependencies updated.
=======
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and better scheduled tasks support!
- **2026-02-04** 🚀 v0.1.3.post4 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-02** 🎉 nanobot launched! Welcome to try 🐈 nanobot!
>>>>>>> main
## Key Features of nanobot: ## Key Features of nanobot:
@ -33,7 +39,7 @@
⚡️ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iterations. ⚡️ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iterations.
💎 **Easy-to-Use**: One-click to depoly and you're ready to go. 💎 **Easy-to-Use**: One-click to deploy and you're ready to go.
## 🏗️ Architecture ## 🏗️ Architecture

View File

@ -118,6 +118,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
current_message: str, current_message: str,
skill_names: list[str] | None = None, skill_names: list[str] | None = None,
media: list[str] | None = None, media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Build the complete message list for an LLM call. Build the complete message list for an LLM call.
@ -127,6 +129,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
current_message: The new user message. current_message: The new user message.
skill_names: Optional skills to include. skill_names: Optional skills to include.
media: Optional list of local file paths for images/media. media: Optional list of local file paths for images/media.
channel: Current channel (telegram, feishu, etc.).
chat_id: Current chat/user ID.
Returns: Returns:
List of messages including system prompt. List of messages including system prompt.
@ -135,6 +139,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
# System prompt # System prompt
system_prompt = self.build_system_prompt(skill_names) system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "system", "content": system_prompt})
# History # History

View File

@ -17,6 +17,7 @@ from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -42,8 +43,10 @@ class AgentLoop:
max_iterations: int = 20, max_iterations: int = 20,
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,
): ):
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
self.bus = bus self.bus = bus
self.provider = provider self.provider = provider
self.workspace = workspace self.workspace = workspace
@ -51,6 +54,7 @@ class AgentLoop:
self.max_iterations = max_iterations self.max_iterations = max_iterations
self.brave_api_key = brave_api_key self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
self.context = ContextBuilder(workspace) self.context = ContextBuilder(workspace)
self.sessions = SessionManager(workspace) self.sessions = SessionManager(workspace)
@ -94,6 +98,10 @@ class AgentLoop:
spawn_tool = SpawnTool(manager=self.subagents) spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool) self.tools.register(spawn_tool)
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
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."""
self._running = True self._running = True
@ -157,11 +165,17 @@ class AgentLoop:
if isinstance(spawn_tool, SpawnTool): if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(msg.channel, msg.chat_id) spawn_tool.set_context(msg.channel, msg.chat_id)
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(msg.channel, msg.chat_id)
# Build initial messages (use get_history for LLM-formatted messages) # Build initial messages (use get_history for LLM-formatted messages)
messages = self.context.build_messages( messages = self.context.build_messages(
history=session.get_history(), history=session.get_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,
) )
# Agent loop # Agent loop
@ -255,10 +269,16 @@ class AgentLoop:
if isinstance(spawn_tool, SpawnTool): if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(origin_channel, origin_chat_id) spawn_tool.set_context(origin_channel, origin_chat_id)
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(origin_channel, origin_chat_id)
# Build messages with the announce content # Build messages with the announce content
messages = self.context.build_messages( messages = self.context.build_messages(
history=session.get_history(), history=session.get_history(),
current_message=msg.content current_message=msg.content,
channel=origin_channel,
chat_id=origin_chat_id,
) )
# Agent loop (limited for announce handling) # Agent loop (limited for announce handling)
@ -315,21 +335,29 @@ class AgentLoop:
content=final_content content=final_content
) )
async def process_direct(self, content: str, session_key: str = "cli:direct") -> str: async def process_direct(
self,
content: str,
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
) -> str:
""" """
Process a message directly (for CLI usage). Process a message directly (for CLI or cron usage).
Args: Args:
content: The message content. content: The message content.
session_key: Session identifier. session_key: Session identifier.
channel: Source channel (for context).
chat_id: Source chat ID (for context).
Returns: Returns:
The agent's response. The agent's response.
""" """
msg = InboundMessage( msg = InboundMessage(
channel="cli", channel=channel,
sender_id="user", sender_id="user",
chat_id="direct", chat_id=chat_id,
content=content content=content
) )

View File

@ -149,7 +149,8 @@ class SubagentManager:
# Execute tools # Execute tools
for tool_call in response.tool_calls: for tool_call in response.tool_calls:
logger.debug(f"Subagent [{task_id}] executing: {tool_call.name}") args_str = json.dumps(tool_call.arguments)
logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}")
result = await tools.execute(tool_call.name, tool_call.arguments) result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({ messages.append({
"role": "tool", "role": "tool",

114
nanobot/agent/tools/cron.py Normal file
View File

@ -0,0 +1,114 @@
"""Cron tool for scheduling reminders and tasks."""
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService):
self._cron = cron_service
self._channel = ""
self._chat_id = ""
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current session context for delivery."""
self._channel = channel
self._chat_id = chat_id
@property
def name(self) -> str:
return "cron"
@property
def description(self) -> str:
return "Schedule reminders and recurring tasks. Actions: add, list, remove."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "Action to perform"
},
"message": {
"type": "string",
"description": "Reminder message (for add)"
},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)"
},
"cron_expr": {
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
}
},
"required": ["action"]
}
async def execute(
self,
action: str,
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
if action == "add":
return self._add_job(message, every_seconds, cron_expr)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
# Build schedule
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr)
else:
return "Error: either every_seconds or cron_expr is required"
job = self._cron.add_job(
name=message[:30],
schedule=schedule,
message=message,
deliver=True,
channel=self._channel,
to=self._chat_id,
)
return f"Created job '{job.name}' (id: {job.id})"
def _list_jobs(self) -> str:
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
return "Scheduled jobs:\n" + "\n".join(lines)
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return "Error: job_id is required for remove"
if self._cron.remove_job(job_id):
return f"Removed job {job_id}"
return f"Job {job_id} not found"

View File

@ -195,7 +195,11 @@ def gateway(
default_model=config.agents.defaults.model default_model=config.agents.defaults.model
) )
# Create agent # Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
agent = AgentLoop( agent = AgentLoop(
bus=bus, bus=bus,
provider=provider, provider=provider,
@ -204,27 +208,27 @@ def gateway(
max_iterations=config.agents.defaults.max_tool_iterations, max_iterations=config.agents.defaults.max_tool_iterations,
brave_api_key=config.tools.web.search.api_key or None, brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec, exec_config=config.tools.exec,
cron_service=cron,
) )
# Create cron service # Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None: async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent.""" """Execute a cron job through the agent."""
response = await agent.process_direct( response = await agent.process_direct(
job.payload.message, job.payload.message,
session_key=f"cron:{job.id}" session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli",
chat_id=job.payload.to or "direct",
) )
# Optionally deliver to channel
if job.payload.deliver and job.payload.to: if job.payload.deliver and job.payload.to:
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
await bus.publish_outbound(OutboundMessage( await bus.publish_outbound(OutboundMessage(
channel=job.payload.channel or "whatsapp", channel=job.payload.channel or "cli",
chat_id=job.payload.to, chat_id=job.payload.to,
content=response or "" content=response or ""
)) ))
return response return response
cron.on_job = on_cron_job
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path, on_job=on_cron_job)
# Create heartbeat service # Create heartbeat service
async def on_heartbeat(prompt: str) -> str: async def on_heartbeat(prompt: str) -> str:

View File

@ -0,0 +1,40 @@
---
name: cron
description: Schedule reminders and recurring tasks.
---
# Cron
Use the `cron` tool to schedule reminders or recurring tasks.
## Two Modes
1. **Reminder** - message is sent directly to user
2. **Task** - message is a task description, agent executes and sends result
## Examples
Fixed reminder:
```
cron(action="add", message="Time to take a break!", every_seconds=1200)
```
Dynamic task (agent executes each time):
```
cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600)
```
List/remove:
```
cron(action="list")
cron(action="remove", job_id="abc123")
```
## Time Expressions
| User says | Parameters |
|-----------|------------|
| every 20 minutes | every_seconds: 1200 |
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |