Merge branch 'main' into pr-1361

This commit is contained in:
Re-bin
2026-03-01 06:30:10 +00:00
33 changed files with 575 additions and 575 deletions

View File

@@ -1,7 +1,7 @@
"""Agent core module."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader

View File

@@ -134,7 +134,7 @@ class SkillsLoader:
if missing:
lines.append(f" <requires>{escape_xml(missing)}</requires>")
lines.append(f" </skill>")
lines.append(" </skill>")
lines.append("</skills>")
return "\n".join(lines)

View File

@@ -8,13 +8,14 @@ from typing import Any
from loguru import logger
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
class SubagentManager:

View File

@@ -84,10 +84,12 @@ class Tool(ABC):
errors.append(f"missing required {path + '.' + k if path else k}")
for k, v in val.items():
if k in props:
errors.extend(self._validate(v, props[k], path + '.' + k if path else k))
errors.extend(self._validate(v, props[k], path + "." + k if path else k))
if t == "array" and "items" in schema:
for i, item in enumerate(val):
errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]"))
errors.extend(
self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")
)
return errors
def to_schema(self) -> dict[str, Any]:
@@ -98,5 +100,5 @@ class Tool(ABC):
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
},
}

View File

@@ -36,34 +36,28 @@ class CronTool(Tool):
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "Action to perform"
},
"message": {
"type": "string",
"description": "Reminder message (for add)"
"description": "Action to perform",
},
"message": {"type": "string", "description": "Reminder message (for add)"},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)"
"description": "Interval in seconds (for recurring tasks)",
},
"cron_expr": {
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)",
},
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')",
},
"at": {
"type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')",
},
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
}
"job_id": {"type": "string", "description": "Job ID (for remove)"},
},
"required": ["action"]
"required": ["action"],
}
async def execute(
@@ -75,7 +69,7 @@ class CronTool(Tool):
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
**kwargs: Any
**kwargs: Any,
) -> str:
if action == "add":
return self._add_job(message, every_seconds, cron_expr, tz, at)
@@ -101,6 +95,7 @@ class CronTool(Tool):
return "Error: tz can only be used with cron_expr"
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
@@ -114,6 +109,7 @@ class CronTool(Tool):
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
from datetime import datetime
dt = datetime.fromisoformat(at)
at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms)

View File

@@ -7,7 +7,9 @@ from typing import Any
from nanobot.agent.tools.base import Tool
def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
def _resolve_path(
path: str, workspace: Path | None = None, allowed_dir: Path | None = None
) -> Path:
"""Resolve path against workspace (if relative) and enforce directory restriction."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
@@ -40,13 +42,8 @@ class ReadFileTool(Tool):
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to read"
}
},
"required": ["path"]
"properties": {"path": {"type": "string", "description": "The file path to read"}},
"required": ["path"],
}
async def execute(self, path: str, **kwargs: Any) -> str:
@@ -85,16 +82,10 @@ class WriteFileTool(Tool):
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to write to"
},
"content": {
"type": "string",
"description": "The content to write"
}
"path": {"type": "string", "description": "The file path to write to"},
"content": {"type": "string", "description": "The content to write"},
},
"required": ["path", "content"]
"required": ["path", "content"],
}
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
@@ -129,20 +120,11 @@ class EditFileTool(Tool):
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to edit"
},
"old_text": {
"type": "string",
"description": "The exact text to find and replace"
},
"new_text": {
"type": "string",
"description": "The text to replace with"
}
"path": {"type": "string", "description": "The file path to edit"},
"old_text": {"type": "string", "description": "The exact text to find and replace"},
"new_text": {"type": "string", "description": "The text to replace with"},
},
"required": ["path", "old_text", "new_text"]
"required": ["path", "old_text", "new_text"],
}
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
@@ -184,13 +166,19 @@ class EditFileTool(Tool):
best_ratio, best_start = ratio, i
if best_ratio > 0.5:
diff = "\n".join(difflib.unified_diff(
old_lines, lines[best_start : best_start + window],
fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
))
diff = "\n".join(
difflib.unified_diff(
old_lines,
lines[best_start : best_start + window],
fromfile="old_text (provided)",
tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
)
)
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
return (
f"Error: old_text not found in {path}. No similar text found. Verify the file content."
)
class ListDirTool(Tool):
@@ -212,13 +200,8 @@ class ListDirTool(Tool):
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to list"
}
},
"required": ["path"]
"properties": {"path": {"type": "string", "description": "The directory path to list"}},
"required": ["path"],
}
async def execute(self, path: str, **kwargs: Any) -> str:

View File

@@ -1,6 +1,6 @@
"""Spawn tool for creating background subagents."""
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool

View File

@@ -9,8 +9,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
from loguru import logger
import httpx
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
@@ -19,11 +19,11 @@ from nanobot.config.schema import DingTalkConfig
try:
from dingtalk_stream import (
DingTalkStreamClient,
Credential,
AckMessage,
CallbackHandler,
CallbackMessage,
AckMessage,
Credential,
DingTalkStreamClient,
)
from dingtalk_stream.chatbot import ChatbotMessage

View File

@@ -14,7 +14,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import DiscordConfig
DISCORD_API_BASE = "https://discord.com/api/v10"
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
MAX_MESSAGE_LEN = 2000 # Discord message character limit

View File

@@ -23,12 +23,11 @@ try:
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
Emoji,
GetFileRequest,
GetMessageResourceRequest,
P2ImMessageReceiveV1,
)
@@ -327,7 +326,8 @@ class FeishuChannel(BaseChannel):
except Exception as e:
logger.warning("Feishu WebSocket error: {}", e)
if self._running:
import time; time.sleep(5)
import time
time.sleep(5)
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
self._ws_thread.start()
@@ -395,12 +395,13 @@ class FeishuChannel(BaseChannel):
@staticmethod
def _parse_md_table(table_text: str) -> dict | None:
"""Parse a markdown table into a Feishu table element."""
lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()]
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
if len(lines) < 3:
return None
split = lambda l: [c.strip() for c in l.strip("|").split("|")]
def split(_line: str) -> list[str]:
return [c.strip() for c in _line.strip("|").split("|")]
headers = split(lines[0])
rows = [split(l) for l in lines[2:]]
rows = [split(_line) for _line in lines[2:]]
columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"}
for i, h in enumerate(headers)]
return {

View File

@@ -12,10 +12,22 @@ try:
import nh3
from mistune import create_markdown
from nio import (
AsyncClient, AsyncClientConfig, ContentRepositoryConfigError,
DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse,
RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText,
RoomSendError, RoomTypingError, SyncError, UploadError,
AsyncClient,
AsyncClientConfig,
ContentRepositoryConfigError,
DownloadError,
InviteEvent,
JoinError,
MatrixRoom,
MemoryDownloadResponse,
RoomEncryptedMedia,
RoomMessage,
RoomMessageMedia,
RoomMessageText,
RoomSendError,
RoomTypingError,
SyncError,
UploadError,
)
from nio.crypto.attachments import decrypt_attachment
from nio.exceptions import EncryptionError

View File

@@ -5,11 +5,10 @@ import re
from typing import Any
from loguru import logger
from slack_sdk.socket_mode.websockets import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.socket_mode.websockets import SocketModeClient
from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage

View File

@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
import re
from loguru import logger
from telegram import BotCommand, Update, ReplyParameters
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from telegram import BotCommand, ReplyParameters, Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage

View File

@@ -3,7 +3,6 @@
import asyncio
import json
from collections import OrderedDict
from typing import Any
from loguru import logger

View File

@@ -2,23 +2,22 @@
import asyncio
import os
import signal
from pathlib import Path
import select
import signal
import sys
from pathlib import Path
import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
from nanobot import __logo__, __version__
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
@@ -201,9 +200,9 @@ def onboard():
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config."""
from nanobot.providers.custom_provider import CustomProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
@@ -248,14 +247,14 @@ def gateway(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the nanobot gateway."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.session.manager import SessionManager
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose:
import logging
@@ -412,12 +411,13 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
config = load_config()
sync_workspace_templates(config.workspace_path)
@@ -737,6 +737,7 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
from nanobot.config.loader import load_config
config = load_config()
@@ -915,11 +916,12 @@ def cron_run(
):
"""Manually run a job."""
from loguru import logger
from nanobot.config.loader import load_config, get_data_dir
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
logger.disable("nanobot")
config = load_config()
@@ -978,7 +980,7 @@ def cron_run(
@app.command()
def status():
"""Show nanobot status."""
from nanobot.config.loader import load_config, get_config_path
from nanobot.config.loader import get_config_path, load_config
config_path = get_config_path()
config = load_config()

View File

@@ -1,6 +1,6 @@
"""Configuration module for nanobot."""
from nanobot.config.loader import load_config, get_config_path
from nanobot.config.loader import get_config_path, load_config
from nanobot.config.schema import Config
__all__ = ["Config", "load_config", "get_config_path"]

View File

@@ -3,7 +3,7 @@
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings

View File

@@ -30,8 +30,9 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
if schedule.kind == "cron" and schedule.expr:
try:
from croniter import croniter
from zoneinfo import ZoneInfo
from croniter import croniter
# Use caller-provided reference time for deterministic scheduling
base_time = now_ms / 1000
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
@@ -68,13 +69,19 @@ class CronService:
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None
):
self.store_path = store_path
self.on_job = on_job # Callback to execute job, returns response text
self.on_job = on_job
self._store: CronStore | None = None
self._last_mtime: float = 0.0
self._timer_task: asyncio.Task | None = None
self._running = False
def _load_store(self) -> CronStore:
"""Load jobs from disk."""
"""Load jobs from disk. Reloads automatically if file was modified externally."""
if self._store and self.store_path.exists():
mtime = self.store_path.stat().st_mtime
if mtime != self._last_mtime:
logger.info("Cron: jobs.json modified externally, reloading")
self._store = None
if self._store:
return self._store
@@ -163,6 +170,7 @@ class CronService:
}
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
self._last_mtime = self.store_path.stat().st_mtime
async def start(self) -> None:
"""Start the cron service."""

View File

@@ -1,19 +1,17 @@
"""LiteLLM provider implementation for multi-provider support."""
import json
import json_repair
import os
import secrets
import string
from typing import Any
import json_repair
import litellm
from litellm import acompletion
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway
# Standard OpenAI chat-completion message keys plus reasoning_content for
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"})

View File

@@ -9,8 +9,8 @@ from typing import Any, AsyncGenerator
import httpx
from loguru import logger
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"

View File

@@ -2,7 +2,6 @@
import os
from pathlib import Path
from typing import Any
import httpx
from loguru import logger

View File

@@ -1,5 +1,5 @@
"""Session management module."""
from nanobot.session.manager import SessionManager, Session
from nanobot.session.manager import Session, SessionManager
__all__ = ["SessionManager", "Session"]

View File

@@ -2,9 +2,9 @@
import json
import shutil
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from loguru import logger

View File

@@ -1,5 +1,5 @@
"""Utility functions for nanobot."""
from nanobot.utils.helpers import ensure_dir, get_workspace_path, get_data_path
from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path
__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"]

View File

@@ -1,8 +1,8 @@
"""Utility functions for nanobot."""
import re
from pathlib import Path
from datetime import datetime
from pathlib import Path
def ensure_dir(path: Path) -> Path: