From 50e0eee893cb94bea47e7b190259504c220cd059 Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Wed, 4 Feb 2026 14:08:41 -0500 Subject: [PATCH 01/85] Add github action to codespell main on push and PRs --- .github/workflows/codespell.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/codespell.yml diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 000000000..dd0eb8e57 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,23 @@ +# Codespell configuration is within pyproject.toml +--- +name: Codespell + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Codespell + uses: codespell-project/actions-codespell@v2 From b51ef6f8860e5cdeab95cd7a993219db4711aeab Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Wed, 4 Feb 2026 14:08:41 -0500 Subject: [PATCH 02/85] Add rudimentary codespell config --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d578a08bf..87b185667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,3 +81,10 @@ ignore = ["E501"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] + +[tool.codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = '.git*' +check-hidden = true +# ignore-regex = '' +# ignore-words-list = '' From 5082a7732a9462274f47a097131f3cda678e4c00 Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Wed, 4 Feb 2026 14:08:41 -0500 Subject: [PATCH 03/85] [DATALAD RUNCMD] chore: run codespell throughout fixing few left typos automagically === Do not change lines below === { "chain": [], "cmd": "codespell -w", "exit": 0, "extra_inputs": [], "inputs": [], "outputs": [], "pwd": "." } ^^^ Do not change lines above ^^^ --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e54bb8fcc..b8088d4b0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ⚡️ **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 @@ -48,7 +48,7 @@

-

+

From a25a24422dba66687d348b44b32e96e4933bca1d Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Wed, 4 Feb 2026 14:09:43 -0500 Subject: [PATCH 04/85] fix filename --- case/{scedule.gif => schedule.gif} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename case/{scedule.gif => schedule.gif} (100%) diff --git a/case/scedule.gif b/case/schedule.gif similarity index 100% rename from case/scedule.gif rename to case/schedule.gif From 7913e7150a5a93ac9c3847f60b213b20c27e3ded Mon Sep 17 00:00:00 2001 From: kinchahoy Date: Mon, 16 Mar 2026 23:55:19 -0700 Subject: [PATCH 05/85] feat: sandbox exec calls with bwrap and run container as non-root --- Dockerfile | 11 +- docker-compose.yml | 5 +- nanobot/agent/loop.py | 3 +- nanobot/agent/subagent.py | 3 +- nanobot/agent/tools/sandbox.py | 49 ++ nanobot/agent/tools/shell.py | 8 + nanobot/config/schema.py | 3 +- podman-seccomp.json | 1129 ++++++++++++++++++++++++++++++++ 8 files changed, 1204 insertions(+), 7 deletions(-) create mode 100644 nanobot/agent/tools/sandbox.py create mode 100644 podman-seccomp.json diff --git a/Dockerfile b/Dockerfile index 81327475c..594a9e7a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim # Install Node.js 20 for the WhatsApp bridge RUN apt-get update && \ - apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ @@ -30,8 +30,13 @@ WORKDIR /app/bridge RUN npm install && npm run build WORKDIR /app -# Create config directory -RUN mkdir -p /root/.nanobot +# Create non-root user and config directory +RUN useradd -m -u 1000 -s /bin/bash nanobot && \ + mkdir -p /home/nanobot/.nanobot && \ + chown -R nanobot:nanobot /home/nanobot /app + +USER nanobot +ENV HOME=/home/nanobot # Gateway default port EXPOSE 18790 diff --git a/docker-compose.yml b/docker-compose.yml index 5c27f81a0..88b9f4d07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,10 @@ x-common-config: &common-config context: . dockerfile: Dockerfile volumes: - - ~/.nanobot:/root/.nanobot + - ~/.nanobot:/home/nanobot/.nanobot + security_opt: + - apparmor=unconfined + - seccomp=./podman-seccomp.json services: nanobot-gateway: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 34f5baa12..1333a89e1 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -115,7 +115,7 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - allowed_dir = self.workspace if self.restrict_to_workspace else None + allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read)) for cls in (WriteFileTool, EditFileTool, ListDirTool): @@ -124,6 +124,7 @@ class AgentLoop: working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, + sandbox=self.exec_config.sandbox, path_append=self.exec_config.path_append, )) self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 30e7913cf..1960bd82c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -92,7 +92,7 @@ class SubagentManager: try: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() - allowed_dir = self.workspace if self.restrict_to_workspace else None + allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read)) tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) @@ -102,6 +102,7 @@ class SubagentManager: working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, + sandbox=self.exec_config.sandbox, path_append=self.exec_config.path_append, )) tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py new file mode 100644 index 000000000..67818ec00 --- /dev/null +++ b/nanobot/agent/tools/sandbox.py @@ -0,0 +1,49 @@ +"""Sandbox backends for shell command execution. + +To add a new backend, implement a function with the signature: + _wrap_(command: str, workspace: str, cwd: str) -> str +and register it in _BACKENDS below. +""" + +import shlex +from pathlib import Path + + +def _bwrap(command: str, workspace: str, cwd: str) -> str: + """Wrap command in a bubblewrap sandbox (requires bwrap in container). + + Only the workspace is bind-mounted read-write; its parent dir (which holds + config.json) is hidden behind a fresh tmpfs. + """ + ws = Path(workspace).resolve() + try: + sandbox_cwd = str(ws / Path(cwd).resolve().relative_to(ws)) + except ValueError: + sandbox_cwd = str(ws) + + required = ["/usr"] + optional = ["/bin", "/lib", "/lib64", "/etc/alternatives", + "/etc/ssl/certs", "/etc/resolv.conf", "/etc/ld.so.cache"] + + args = ["bwrap"] + for p in required: args += ["--ro-bind", p, p] + for p in optional: args += ["--ro-bind-try", p, p] + args += [ + "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", + "--tmpfs", str(ws.parent), # mask config dir + "--dir", str(ws), # recreate workspace mount point + "--bind", str(ws), str(ws), + "--chdir", sandbox_cwd, + "--", "sh", "-c", command, + ] + return shlex.join(args) + + +_BACKENDS = {"bwrap": _bwrap} + + +def wrap_command(sandbox: str, command: str, workspace: str, cwd: str) -> str: + """Wrap *command* using the named sandbox backend.""" + if backend := _BACKENDS.get(sandbox): + return backend(command, workspace, cwd) + raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}") diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 4b10c83a3..4bdeda6ec 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.sandbox import wrap_command class ExecTool(Tool): @@ -19,10 +20,12 @@ class ExecTool(Tool): deny_patterns: list[str] | None = None, allow_patterns: list[str] | None = None, restrict_to_workspace: bool = False, + sandbox: str = "", path_append: str = "", ): self.timeout = timeout self.working_dir = working_dir + self.sandbox = sandbox self.deny_patterns = deny_patterns or [ r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr r"\bdel\s+/[fq]\b", # del /f, del /q @@ -84,6 +87,11 @@ class ExecTool(Tool): if guard_error: return guard_error + if self.sandbox: + workspace = self.working_dir or cwd + command = wrap_command(self.sandbox, command, workspace, cwd) + cwd = str(Path(workspace).resolve()) + effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT) env = os.environ.copy() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 033fb633a..dee8c5f34 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -128,6 +128,7 @@ class ExecToolConfig(Base): timeout: int = 60 path_append: str = "" + sandbox: str = "" # sandbox backend: "" (none) or "bwrap" class MCPServerConfig(Base): @@ -147,7 +148,7 @@ class ToolsConfig(Base): web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) - restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory + restrict_to_workspace: bool = False # restrict all tool access to workspace directory mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) diff --git a/podman-seccomp.json b/podman-seccomp.json new file mode 100644 index 000000000..92d882b5c --- /dev/null +++ b/podman-seccomp.json @@ -0,0 +1,1129 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 38, + "defaultErrno": "ENOSYS", + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + } + ], + "syscalls": [ + { + "names": [ + "bdflush", + "cachestat", + "futex_requeue", + "futex_wait", + "futex_waitv", + "futex_wake", + "io_pgetevents", + "io_pgetevents_time64", + "kexec_file_load", + "kexec_load", + "map_shadow_stack", + "migrate_pages", + "move_pages", + "nfsservctl", + "nice", + "oldfstat", + "oldlstat", + "oldolduname", + "oldstat", + "olduname", + "pciconfig_iobase", + "pciconfig_read", + "pciconfig_write", + "sgetmask", + "ssetmask", + "swapoff", + "swapon", + "syscall", + "sysfs", + "uselib", + "userfaultfd", + "ustat", + "vm86", + "vm86old", + "vmsplice" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": {}, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_init", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsconfig", + "fsetxattr", + "fsmount", + "fsopen", + "fspick", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_time64", + "futimesat", + "get_mempolicy", + "get_robust_list", + "get_thread_area", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_setup", + "io_submit", + "ioctl", + "ioprio_get", + "ioprio_set", + "ipc", + "keyctl", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "mbind", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mount", + "mount_setattr", + "move_mount", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "open", + "open_tree", + "openat", + "openat2", + "pause", + "pidfd_getfd", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pivot_root", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "pselect6", + "pselect6_time64", + "ptrace", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "reboot", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_mempolicy", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setns", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signal", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "syslog", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "umount", + "umount2", + "uname", + "unlink", + "unlinkat", + "unshare", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "ppc64le" + ] + }, + "excludes": {} + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "breakpoint", + "cacheflush", + "set_tls", + "sync_file_range2" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "arm", + "arm64" + ] + }, + "excludes": {} + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32" + ] + }, + "excludes": {} + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + }, + "excludes": {} + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": {} + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "riscv64" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf", + "lookup_dcookie", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "excludes": {} + }, + { + "names": [ + "lookup_dcookie", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "excludes": {} + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "excludes": {} + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "excludes": {} + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "excludes": {} + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "excludes": {} + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "excludes": {} + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "excludes": {} + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ERRNO", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + }, + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "errnoRet": 22, + "errno": "EINVAL" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": null, + "comment": "", + "includes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "excludes": {} + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_BPF" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_BPF" + ] + }, + "excludes": {} + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_BPF" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + }, + "excludes": {} + } + ] +} \ No newline at end of file From b26a93c14aba35618217d5b4664be8311d0bf7d6 Mon Sep 17 00:00:00 2001 From: MrBob Date: Tue, 24 Mar 2026 15:56:23 -0300 Subject: [PATCH 06/85] fix: preserve cron reminder context for notifications --- nanobot/cli/commands.py | 40 ++++++++---- nanobot/utils/evaluator.py | 6 +- tests/cli/test_commands.py | 123 ++++++++++++++++++++++++++++++++++--- 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 91c81d3de..25f64137f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,12 +1,11 @@ """CLI commands for nanobot.""" import asyncio -from contextlib import contextmanager, nullcontext - import os import select import signal import sys +from contextlib import nullcontext from pathlib import Path from typing import Any @@ -67,6 +66,7 @@ def _flush_pending_tty_input() -> None: try: import termios + termios.tcflush(fd, termios.TCIFLUSH) return except Exception: @@ -89,6 +89,7 @@ def _restore_terminal() -> None: return try: import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) except Exception: pass @@ -101,6 +102,7 @@ def _init_prompt_session() -> None: # Save terminal state so we can restore it on exit try: import termios + _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) except Exception: pass @@ -113,7 +115,7 @@ def _init_prompt_session() -> None: _PROMPT_SESSION = PromptSession( history=FileHistory(str(history_file)), enable_open_in_editor=False, - multiline=False, # Enter submits (single line mode) + multiline=False, # Enter submits (single line mode) ) @@ -225,7 +227,6 @@ async def _read_interactive_input_async() -> str: raise KeyboardInterrupt from exc - def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -275,8 +276,12 @@ def onboard( config = _apply_workspace_override(load_config(config_path)) else: console.print(f"[yellow]Config already exists at {config_path}[/yellow]") - console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") - console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") + console.print( + " [bold]y[/bold] = overwrite with defaults (existing values will be lost)" + ) + console.print( + " [bold]N[/bold] = refresh config, keeping existing values and adding new fields" + ) if typer.confirm("Overwrite?"): config = _apply_workspace_override(Config()) save_config(config, config_path) @@ -284,7 +289,9 @@ def onboard( else: config = _apply_workspace_override(load_config(config_path)) save_config(config, config_path) - console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") + console.print( + f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)" + ) else: config = _apply_workspace_override(Config()) # In wizard mode, don't save yet - the wizard will handle saving if should_save=True @@ -334,7 +341,9 @@ def onboard( console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]") - console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") + console.print( + "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]" + ) def _merge_missing_defaults(existing: Any, defaults: Any) -> Any: @@ -407,9 +416,11 @@ def _make_provider(config: Config): # --- instantiation by backend --- if backend == "openai_codex": from nanobot.providers.openai_codex_provider import OpenAICodexProvider + provider = OpenAICodexProvider(default_model=model) elif backend == "azure_openai": from nanobot.providers.azure_openai_provider import AzureOpenAIProvider + provider = AzureOpenAIProvider( api_key=p.api_key, api_base=p.api_base, @@ -417,6 +428,7 @@ def _make_provider(config: Config): ) elif backend == "anthropic": from nanobot.providers.anthropic_provider import AnthropicProvider + provider = AnthropicProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), @@ -425,6 +437,7 @@ def _make_provider(config: Config): ) else: from nanobot.providers.openai_compat_provider import OpenAICompatProvider + provider = OpenAICompatProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), @@ -465,6 +478,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None def _warn_deprecated_config_keys(config_path: Path | None) -> None: """Hint users to remove obsolete keys from their config file.""" import json + from nanobot.config.loader import get_config_path path = config_path or get_config_path() @@ -488,6 +502,7 @@ def _migrate_cron_store(config: "Config") -> None: if legacy_path.is_file() and not new_path.exists(): new_path.parent.mkdir(parents=True, exist_ok=True) import shutil + shutil.move(str(legacy_path), str(new_path)) @@ -514,6 +529,7 @@ def gateway( if verbose: import logging + logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) @@ -587,7 +603,7 @@ def gateway( if job.payload.deliver and job.payload.to and response: should_notify = await evaluate_response( - response, job.payload.message, provider, agent.model, + response, reminder_note, provider, agent.model, ) if should_notify: from nanobot.bus.events import OutboundMessage @@ -597,6 +613,7 @@ def gateway( content=response, )) return response + cron.on_job = on_cron_job # Create channel manager @@ -684,6 +701,7 @@ def gateway( console.print("\nShutting down...") except Exception: import traceback + console.print("\n[red]Error: Gateway crashed unexpectedly[/red]") console.print(traceback.format_exc()) finally: @@ -696,8 +714,6 @@ def gateway( asyncio.run(run()) - - # ============================================================================ # Agent Commands # ============================================================================ @@ -1149,6 +1165,7 @@ def _register_login(name: str): def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn + return decorator @@ -1179,6 +1196,7 @@ def provider_login( def _login_openai_codex() -> None: try: from oauth_cli_kit import get_token, login_oauth_interactive + token = None try: token = get_token() diff --git a/nanobot/utils/evaluator.py b/nanobot/utils/evaluator.py index 61104719e..cab174f6e 100644 --- a/nanobot/utils/evaluator.py +++ b/nanobot/utils/evaluator.py @@ -43,8 +43,10 @@ _SYSTEM_PROMPT = ( "Call the evaluate_notification tool to decide whether the user " "should be notified.\n\n" "Notify when the response contains actionable information, errors, " - "completed deliverables, or anything the user explicitly asked to " - "be reminded about.\n\n" + "completed deliverables, scheduled reminder/timer completions, or " + "anything the user explicitly asked to be reminded about.\n\n" + "A user-scheduled reminder should usually notify even when the " + "response is brief or mostly repeats the original reminder.\n\n" "Suppress when the response is a routine status check with nothing " "new, a confirmation that everything is normal, or essentially empty." ) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index a8fcc4aa0..fdfd96908 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1,5 +1,7 @@ +import asyncio import json import re +import shutil from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -9,6 +11,7 @@ from typer.testing import CliRunner from nanobot.bus.events import OutboundMessage from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config +from nanobot.cron.types import CronJob, CronPayload from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.registry import find_by_name @@ -19,11 +22,6 @@ class _StopGatewayError(RuntimeError): pass -import shutil - -import pytest - - @pytest.fixture def mock_paths(): """Mock config/workspace paths for test isolation.""" @@ -31,7 +29,6 @@ def mock_paths(): patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.load_config") as mock_lc, \ patch("nanobot.cli.commands.get_workspace_path") as mock_ws: - base_dir = Path("./test_onboard_data") if base_dir.exists(): shutil.rmtree(base_dir) @@ -362,7 +359,6 @@ def mock_agent_runtime(tmp_path): patch("nanobot.bus.queue.MessageBus"), \ patch("nanobot.cron.service.CronService"), \ patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls: - agent_loop = MagicMock() agent_loop.channels_config = None agent_loop.process_direct = AsyncMock( @@ -587,7 +583,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron( monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) - monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None + ) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) @@ -732,6 +730,115 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" +def test_gateway_cron_evaluator_receives_scheduled_reminder_context( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + provider = object() + bus = MagicMock() + bus.publish_outbound = AsyncMock() + seen: dict[str, object] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus) + monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + + class _FakeCron: + def __init__(self, _store_path: Path) -> None: + self.on_job = None + seen["cron"] = self + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + self.model = "test-model" + self.tools = {} + + async def process_direct(self, *_args, **_kwargs): + return OutboundMessage( + channel="telegram", + chat_id="user-1", + content="Time to stretch.", + ) + + async def close_mcp(self) -> None: + return None + + async def run(self) -> None: + return None + + def stop(self) -> None: + return None + + class _StopAfterCronSetup: + def __init__(self, *_args, **_kwargs) -> None: + raise _StopGatewayError("stop") + + async def _capture_evaluate_response( + response: str, + task_context: str, + provider_arg: object, + model: str, + ) -> bool: + seen["response"] = response + seen["task_context"] = task_context + seen["provider"] = provider_arg + seen["model"] = model + return True + + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup) + monkeypatch.setattr( + "nanobot.utils.evaluator.evaluate_response", + _capture_evaluate_response, + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGatewayError) + cron = seen["cron"] + assert isinstance(cron, _FakeCron) + assert cron.on_job is not None + + job = CronJob( + id="cron-1", + name="stretch", + payload=CronPayload( + message="Remind me to stretch.", + deliver=True, + channel="telegram", + to="user-1", + ), + ) + + response = asyncio.run(cron.on_job(job)) + + assert response == "Time to stretch." + assert seen["response"] == "Time to stretch." + assert seen["provider"] is provider + assert seen["model"] == "test-model" + assert seen["task_context"] == ( + "[Scheduled Task] Timer finished.\n\n" + "Task 'stretch' has been triggered.\n" + "Scheduled instruction: Remind me to stretch." + ) + bus.publish_outbound.assert_awaited_once_with( + OutboundMessage( + channel="telegram", + chat_id="user-1", + content="Time to stretch.", + ) + ) + + def test_gateway_workspace_override_does_not_migrate_legacy_cron( monkeypatch, tmp_path: Path ) -> None: From e8e85cd1bcac8b3bceca3b13d9468c8886decc28 Mon Sep 17 00:00:00 2001 From: Michael-lhh Date: Thu, 26 Mar 2026 22:38:40 +0800 Subject: [PATCH 07/85] fix(telegram): split oversized final streamed replies Prevent Telegram Message_too_long failures on stream finalization by editing only the first chunk and sending overflow chunks as follow-up messages. Made-with: Cursor --- nanobot/channels/telegram.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index feb908657..49d9cf257 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -498,8 +498,10 @@ class TelegramChannel(BaseChannel): if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id: return self._stop_typing(chat_id) + chunks = split_message(buf.text, TELEGRAM_MAX_MESSAGE_LEN) + primary_text = chunks[0] if chunks else buf.text try: - html = _markdown_to_telegram_html(buf.text) + html = _markdown_to_telegram_html(primary_text) await self._call_with_retry( self._app.bot.edit_message_text, chat_id=int_chat_id, message_id=buf.message_id, @@ -515,15 +517,18 @@ class TelegramChannel(BaseChannel): await self._call_with_retry( self._app.bot.edit_message_text, chat_id=int_chat_id, message_id=buf.message_id, - text=buf.text, + text=primary_text, ) except Exception as e2: if self._is_not_modified_error(e2): logger.debug("Final stream plain edit already applied for {}", chat_id) - self._stream_bufs.pop(chat_id, None) - return - logger.warning("Final stream edit failed: {}", e2) - raise # Let ChannelManager handle retry + else: + logger.warning("Final stream edit failed: {}", e2) + raise # Let ChannelManager handle retry + # If final content exceeds Telegram limit, keep the first chunk in + # the edited stream message and send the rest as follow-up messages. + for extra_chunk in chunks[1:]: + await self._send_text(int_chat_id, extra_chunk) self._stream_bufs.pop(chat_id, None) return From db50dd8a772326e8425ba6581e75f757670db1f9 Mon Sep 17 00:00:00 2001 From: comadreja Date: Thu, 26 Mar 2026 21:46:31 -0500 Subject: [PATCH 08/85] feat(whatsapp): add voice message transcription via OpenAI/Groq Whisper Automatically transcribe WhatsApp voice messages using OpenAI Whisper or Groq. Configurable via transcriptionProvider and transcriptionApiKey. Config: "whatsapp": { "transcriptionProvider": "openai", "transcriptionApiKey": "sk-..." } --- nanobot/channels/base.py | 12 ++++++++---- nanobot/channels/whatsapp.py | 19 ++++++++++++++----- nanobot/providers/transcription.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 86e991344..e0bb62c0f 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -37,13 +37,17 @@ class BaseChannel(ABC): self._running = False async def transcribe_audio(self, file_path: str | Path) -> str: - """Transcribe an audio file via Groq Whisper. Returns empty string on failure.""" + """Transcribe an audio file via Whisper (OpenAI or Groq). Returns empty string on failure.""" if not self.transcription_api_key: return "" try: - from nanobot.providers.transcription import GroqTranscriptionProvider - - provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) + provider_name = getattr(self, "transcription_provider", "groq") + if provider_name == "openai": + from nanobot.providers.transcription import OpenAITranscriptionProvider + provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key) + else: + from nanobot.providers.transcription import GroqTranscriptionProvider + provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) return await provider.transcribe(file_path) except Exception as e: logger.warning("{}: audio transcription failed: {}", self.name, e) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 95bde46e9..63a9b69d0 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -26,6 +26,8 @@ class WhatsAppConfig(Base): bridge_url: str = "ws://localhost:3001" bridge_token: str = "" allow_from: list[str] = Field(default_factory=list) + transcription_provider: str = "openai" # openai or groq + transcription_api_key: str = "" group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned @@ -51,6 +53,8 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() + self.transcription_api_key = config.transcription_api_key + self.transcription_provider = config.transcription_provider async def login(self, force: bool = False) -> bool: """ @@ -203,11 +207,16 @@ class WhatsAppChannel(BaseChannel): # Handle voice transcription if it's a voice message if content == "[Voice Message]": - logger.info( - "Voice message received from {}, but direct download from bridge is not yet supported.", - sender_id, - ) - content = "[Voice Message: Transcription not available for WhatsApp yet]" + if media_paths: + logger.info("Transcribing voice message from {}...", sender_id) + transcription = await self.transcribe_audio(media_paths[0]) + if transcription: + content = transcription + logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) + else: + content = "[Voice Message: Transcription failed]" + else: + content = "[Voice Message: Audio not available]" # Extract media paths (images/documents/videos downloaded by the bridge) media_paths = data.get("media") or [] diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 1c8cb6a3f..d432d24fd 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -1,8 +1,36 @@ -"""Voice transcription provider using Groq.""" +"""Voice transcription providers (Groq and OpenAI Whisper).""" import os from pathlib import Path + +class OpenAITranscriptionProvider: + """Voice transcription provider using OpenAI's Whisper API.""" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.environ.get("OPENAI_API_KEY") + self.api_url = "https://api.openai.com/v1/audio/transcriptions" + + async def transcribe(self, file_path: str | Path) -> str: + if not self.api_key: + return "" + path = Path(file_path) + if not path.exists(): + return "" + try: + import httpx + async with httpx.AsyncClient() as client: + with open(path, "rb") as f: + files = {"file": (path.name, f), "model": (None, "whisper-1")} + headers = {"Authorization": f"Bearer {self.api_key}"} + response = await client.post( + self.api_url, headers=headers, files=files, timeout=60.0, + ) + response.raise_for_status() + return response.json().get("text", "") + except Exception: + return "" + import httpx from loguru import logger From 59396bdbef4a2ac1ae16edb83473bb11468c57f4 Mon Sep 17 00:00:00 2001 From: comadreja Date: Thu, 26 Mar 2026 21:48:30 -0500 Subject: [PATCH 09/85] fix(whatsapp): detect phone vs LID by JID suffix, not field name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's pn/sender fields don't consistently map to phone/LID across different versions. Classify by JID suffix instead: @s.whatsapp.net → phone number @lid.whatsapp.net → LID (internal WhatsApp identifier) This ensures allowFrom works reliably with phone numbers regardless of which field the bridge populates. --- nanobot/channels/whatsapp.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 95bde46e9..c4c011304 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -51,6 +51,7 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() + self._lid_to_phone: dict[str, str] = {} async def login(self, force: bool = False) -> bool: """ @@ -197,9 +198,28 @@ class WhatsAppChannel(BaseChannel): if not was_mentioned: return - user_id = pn if pn else sender - sender_id = user_id.split("@")[0] if "@" in user_id else user_id - logger.info("Sender {}", sender) + # Classify by JID suffix: @s.whatsapp.net = phone, @lid.whatsapp.net = LID + # The bridge's pn/sender fields don't consistently map to phone/LID across versions. + raw_a = pn or "" + raw_b = sender or "" + id_a = raw_a.split("@")[0] if "@" in raw_a else raw_a + id_b = raw_b.split("@")[0] if "@" in raw_b else raw_b + + phone_id = "" + lid_id = "" + for raw, extracted in [(raw_a, id_a), (raw_b, id_b)]: + if "@s.whatsapp.net" in raw: + phone_id = extracted + elif "@lid.whatsapp.net" in raw: + lid_id = extracted + elif extracted and not phone_id: + phone_id = extracted # best guess for bare values + + if phone_id and lid_id: + self._lid_to_phone[lid_id] = phone_id + sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b + + logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) # Handle voice transcription if it's a voice message if content == "[Voice Message]": From b951b37c979b66ac0a48565c5c3b103b0a8b554f Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Thu, 2 Apr 2026 18:14:09 +0000 Subject: [PATCH 10/85] fix: use structured error metadata for app-layer retry --- nanobot/providers/anthropic_provider.py | 74 ++++++++++++++- nanobot/providers/base.py | 34 ++++++- nanobot/providers/openai_compat_provider.py | 83 +++++++++++++++- .../providers/test_provider_error_metadata.py | 77 +++++++++++++++ tests/providers/test_provider_retry.py | 95 ++++++++++++++++++- 5 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 tests/providers/test_provider_error_metadata.py diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index eaec77789..9eb04922b 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio +import email.utils import os import re import secrets import string +import time from collections.abc import Awaitable, Callable from typing import Any @@ -51,6 +53,73 @@ class AnthropicProvider(LLMProvider): client_kw["default_headers"] = extra_headers self._client = AsyncAnthropic(**client_kw) + @staticmethod + def _parse_retry_after_headers(headers: Any) -> float | None: + if headers is None: + return None + + try: + retry_ms = headers.get("retry-after-ms") + if retry_ms is not None: + value = float(retry_ms) / 1000.0 + if value > 0: + return value + except (TypeError, ValueError): + pass + + retry_after = headers.get("retry-after") + try: + if retry_after is not None: + value = float(retry_after) + if value > 0: + return value + except (TypeError, ValueError): + pass + + if retry_after is None: + return None + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + return None + retry_date = email.utils.mktime_tz(retry_date_tuple) + value = float(retry_date - time.time()) + return value if value > 0 else None + + @classmethod + def _error_response(cls, e: Exception) -> LLMResponse: + response = getattr(e, "response", None) + headers = getattr(response, "headers", None) + + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + + should_retry: bool | None = None + if headers is not None: + raw = headers.get("x-should-retry") + if isinstance(raw, str): + lowered = raw.strip().lower() + if lowered == "true": + should_retry = True + elif lowered == "false": + should_retry = False + + error_kind: str | None = None + error_name = e.__class__.__name__.lower() + if "timeout" in error_name: + error_kind = "timeout" + elif "connection" in error_name: + error_kind = "connection" + + return LLMResponse( + content=f"Error calling LLM: {e}", + finish_reason="error", + error_status_code=int(status_code) if status_code is not None else None, + error_kind=error_kind, + error_retry_after_s=cls._parse_retry_after_headers(headers), + error_should_retry=should_retry, + ) + @staticmethod def _strip_prefix(model: str) -> str: if model.startswith("anthropic/"): @@ -419,7 +488,7 @@ class AnthropicProvider(LLMProvider): response = await self._client.messages.create(**kwargs) return self._parse_response(response) except Exception as e: - return LLMResponse(content=f"Error calling LLM: {e}", finish_reason="error") + return self._error_response(e) async def chat_stream( self, @@ -462,9 +531,10 @@ class AnthropicProvider(LLMProvider): f"{idle_timeout_s} seconds" ), finish_reason="error", + error_kind="timeout", ) except Exception as e: - return LLMResponse(content=f"Error calling LLM: {e}", finish_reason="error") + return self._error_response(e) def get_default_model(self) -> str: return self.default_model diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 852e9c973..4ad24b785 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -51,6 +51,11 @@ class LLMResponse: usage: dict[str, int] = field(default_factory=dict) reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. thinking_blocks: list[dict] | None = None # Anthropic extended thinking + # Structured error metadata used by retry policy when finish_reason == "error". + error_status_code: int | None = None + error_kind: str | None = None # e.g. "timeout", "connection" + error_retry_after_s: float | None = None + error_should_retry: bool | None = None @property def has_tool_calls(self) -> bool: @@ -88,6 +93,8 @@ class LLMProvider(ABC): "server error", "temporarily unavailable", ) + _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429}) + _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"}) _SENTINEL = object() @@ -191,6 +198,23 @@ class LLMProvider(ABC): err = (content or "").lower() return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS) + @classmethod + def _is_transient_response(cls, response: LLMResponse) -> bool: + """Prefer structured error metadata, fallback to text markers for legacy providers.""" + if response.error_should_retry is not None: + return bool(response.error_should_retry) + + if response.error_status_code is not None: + status = int(response.error_status_code) + if status in cls._RETRYABLE_STATUS_CODES or status >= 500: + return True + + kind = (response.error_kind or "").strip().lower() + if kind in cls._TRANSIENT_ERROR_KINDS: + return True + + return cls._is_transient_error(response.content) + @staticmethod def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None: """Replace image_url blocks with text placeholder. Returns None if no images found.""" @@ -345,6 +369,12 @@ class LLMProvider(ABC): return value * 60.0 return value + @classmethod + def _extract_retry_after_from_response(cls, response: LLMResponse) -> float | None: + if response.error_retry_after_s is not None and response.error_retry_after_s > 0: + return response.error_retry_after_s + return cls._extract_retry_after(response.content) + async def _sleep_with_heartbeat( self, delay: float, @@ -393,7 +423,7 @@ class LLMProvider(ABC): last_error_key = error_key identical_error_count = 1 if error_key else 0 - if not self._is_transient_error(response.content): + if not self._is_transient_response(response): stripped = self._strip_image_content(original_messages) if stripped is not None and stripped != kw["messages"]: logger.warning( @@ -416,7 +446,7 @@ class LLMProvider(ABC): break base_delay = delays[min(attempt - 1, len(delays) - 1)] - delay = self._extract_retry_after(response.content) or base_delay + delay = self._extract_retry_after_from_response(response) or base_delay if persistent: delay = min(delay, self._PERSISTENT_MAX_DELAY) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 3e0a34fbf..268905b58 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio +import email.utils import hashlib import os import secrets import string +import time import uuid from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -569,11 +571,85 @@ class OpenAICompatProvider(LLMProvider): usage=usage, ) + @staticmethod + def _parse_retry_after_headers(headers: Any) -> float | None: + if headers is None: + return None + + try: + retry_ms = headers.get("retry-after-ms") + if retry_ms is not None: + value = float(retry_ms) / 1000.0 + if value > 0: + return value + except (TypeError, ValueError): + pass + + retry_after = headers.get("retry-after") + try: + if retry_after is not None: + value = float(retry_after) + if value > 0: + return value + except (TypeError, ValueError): + pass + + if retry_after is None: + return None + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + return None + retry_date = email.utils.mktime_tz(retry_date_tuple) + value = float(retry_date - time.time()) + return value if value > 0 else None + + @classmethod + def _extract_error_metadata(cls, e: Exception) -> dict[str, Any]: + response = getattr(e, "response", None) + headers = getattr(response, "headers", None) + + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + + should_retry: bool | None = None + if headers is not None: + raw = headers.get("x-should-retry") + if isinstance(raw, str): + lowered = raw.strip().lower() + if lowered == "true": + should_retry = True + elif lowered == "false": + should_retry = False + + error_kind: str | None = None + error_name = e.__class__.__name__.lower() + if "timeout" in error_name: + error_kind = "timeout" + elif "connection" in error_name: + error_kind = "connection" + + return { + "error_status_code": int(status_code) if status_code is not None else None, + "error_kind": error_kind, + "error_retry_after_s": cls._parse_retry_after_headers(headers), + "error_should_retry": should_retry, + } + @staticmethod def _handle_error(e: Exception) -> LLMResponse: - body = getattr(e, "doc", None) or getattr(getattr(e, "response", None), "text", None) - msg = f"Error: {body.strip()[:500]}" if body and body.strip() else f"Error calling LLM: {e}" - return LLMResponse(content=msg, finish_reason="error") + body = ( + getattr(e, "doc", None) + or getattr(e, "body", None) + or getattr(getattr(e, "response", None), "text", None) + ) + body_text = body if isinstance(body, str) else str(body) if body is not None else "" + msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" + return LLMResponse( + content=msg, + finish_reason="error", + **OpenAICompatProvider._extract_error_metadata(e), + ) # ------------------------------------------------------------------ # Public API @@ -641,6 +717,7 @@ class OpenAICompatProvider(LLMProvider): f"{idle_timeout_s} seconds" ), finish_reason="error", + error_kind="timeout", ) except Exception as e: return self._handle_error(e) diff --git a/tests/providers/test_provider_error_metadata.py b/tests/providers/test_provider_error_metadata.py new file mode 100644 index 000000000..b13c667de --- /dev/null +++ b/tests/providers/test_provider_error_metadata.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +from nanobot.providers.anthropic_provider import AnthropicProvider +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + +def _fake_response( + *, + status_code: int, + headers: dict[str, str] | None = None, + text: str = "", +) -> SimpleNamespace: + return SimpleNamespace( + status_code=status_code, + headers=headers or {}, + text=text, + ) + + +def test_openai_handle_error_extracts_structured_metadata() -> None: + class FakeStatusError(Exception): + pass + + err = FakeStatusError("boom") + err.status_code = 409 + err.response = _fake_response( + status_code=409, + headers={"retry-after-ms": "250", "x-should-retry": "false"}, + text='{"error":"conflict"}', + ) + err.body = {"error": "conflict"} + + response = OpenAICompatProvider._handle_error(err) + + assert response.finish_reason == "error" + assert response.error_status_code == 409 + assert response.error_retry_after_s == 0.25 + assert response.error_should_retry is False + + +def test_openai_handle_error_marks_timeout_kind() -> None: + class FakeTimeoutError(Exception): + pass + + response = OpenAICompatProvider._handle_error(FakeTimeoutError("timeout")) + + assert response.finish_reason == "error" + assert response.error_kind == "timeout" + + +def test_anthropic_error_response_extracts_structured_metadata() -> None: + class FakeStatusError(Exception): + pass + + err = FakeStatusError("boom") + err.status_code = 408 + err.response = _fake_response( + status_code=408, + headers={"retry-after": "1.5", "x-should-retry": "true"}, + ) + + response = AnthropicProvider._error_response(err) + + assert response.finish_reason == "error" + assert response.error_status_code == 408 + assert response.error_retry_after_s == 1.5 + assert response.error_should_retry is True + + +def test_anthropic_error_response_marks_connection_kind() -> None: + class FakeConnectionError(Exception): + pass + + response = AnthropicProvider._error_response(FakeConnectionError("connection")) + + assert response.finish_reason == "error" + assert response.error_kind == "connection" diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index 1d8facf52..eb9e7c54c 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -240,6 +240,100 @@ async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypa assert progress and "7s" in progress[0] +@pytest.mark.asyncio +async def test_chat_with_retry_retries_structured_status_code_without_keyword(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content="request failed", + finish_reason="error", + error_status_code=409, + ), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.content == "ok" + assert provider.calls == 2 + assert delays == [1] + + +@pytest.mark.asyncio +async def test_chat_with_retry_retries_structured_timeout_kind(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content="request failed", + finish_reason="error", + error_kind="timeout", + ), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.content == "ok" + assert provider.calls == 2 + assert delays == [1] + + +@pytest.mark.asyncio +async def test_chat_with_retry_structured_should_retry_false_disables_retry(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content="429 rate limit", + finish_reason="error", + error_should_retry=False, + ), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.finish_reason == "error" + assert provider.calls == 1 + assert delays == [] + + +@pytest.mark.asyncio +async def test_chat_with_retry_prefers_structured_retry_after(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content="429 rate limit, retry after 99s", + finish_reason="error", + error_retry_after_s=0.2, + ), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.content == "ok" + assert delays == [0.2] + + @pytest.mark.asyncio async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monkeypatch) -> None: provider = ScriptedProvider([ @@ -263,4 +357,3 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk assert provider.calls == 10 assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4] - From 31d3061a0aa32e20c21c6db677a0dbf6ae11d64b Mon Sep 17 00:00:00 2001 From: pikaxinge <2392811793@qq.com> Date: Sat, 4 Apr 2026 05:23:21 +0000 Subject: [PATCH 11/85] fix(retry): classify 429 as WAIT vs STOP using semantic signals --- nanobot/providers/anthropic_provider.py | 18 ++- nanobot/providers/base.py | 103 ++++++++++++++++++ nanobot/providers/openai_compat_provider.py | 15 +++ .../providers/test_provider_error_metadata.py | 8 +- tests/providers/test_provider_retry.py | 54 ++++++++- 5 files changed, 194 insertions(+), 4 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 3a5e435f0..230250566 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -102,7 +102,20 @@ class AnthropicProvider(LLMProvider): def _error_response(cls, e: Exception) -> LLMResponse: response = getattr(e, "response", None) headers = getattr(response, "headers", None) - msg = f"Error calling LLM: {e}" + payload = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + if payload is None and response is not None: + response_json = getattr(response, "json", None) + if callable(response_json): + try: + payload = response_json() + except Exception: + payload = None + payload_text = payload if isinstance(payload, str) else str(payload) if payload is not None else "" + msg = f"Error: {payload_text.strip()[:500]}" if payload_text.strip() else f"Error calling LLM: {e}" retry_after = cls._parse_retry_after_headers(headers) if retry_after is None: retry_after = LLMProvider._extract_retry_after(msg) @@ -127,6 +140,7 @@ class AnthropicProvider(LLMProvider): error_kind = "timeout" elif "connection" in error_name: error_kind = "connection" + error_type, error_code = LLMProvider._extract_error_type_code(payload) return LLMResponse( content=msg, @@ -134,6 +148,8 @@ class AnthropicProvider(LLMProvider): retry_after=retry_after, error_status_code=int(status_code) if status_code is not None else None, error_kind=error_kind, + error_type=error_type, + error_code=error_code, error_retry_after_s=retry_after, error_should_retry=should_retry, ) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 6e6468a9c..0eb93cc5e 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -57,6 +57,8 @@ class LLMResponse: # Structured error metadata used by retry policy when finish_reason == "error". error_status_code: int | None = None error_kind: str | None = None # e.g. "timeout", "connection" + error_type: str | None = None # Provider/type semantic, e.g. insufficient_quota. + error_code: str | None = None # Provider/code semantic, e.g. rate_limit_exceeded. error_retry_after_s: float | None = None error_should_retry: bool | None = None @@ -98,6 +100,50 @@ class LLMProvider(ABC): ) _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429}) _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"}) + _NON_RETRYABLE_429_ERROR_TOKENS = frozenset({ + "insufficient_quota", + "quota_exceeded", + "quota_exhausted", + "billing_hard_limit_reached", + "insufficient_balance", + "credit_balance_too_low", + "billing_not_active", + "payment_required", + }) + _RETRYABLE_429_ERROR_TOKENS = frozenset({ + "rate_limit_exceeded", + "rate_limit_error", + "too_many_requests", + "request_limit_exceeded", + "requests_limit_exceeded", + "overloaded_error", + }) + _NON_RETRYABLE_429_TEXT_MARKERS = ( + "insufficient_quota", + "insufficient quota", + "quota exceeded", + "quota exhausted", + "billing hard limit", + "billing_hard_limit_reached", + "billing not active", + "insufficient balance", + "insufficient_balance", + "credit balance too low", + "payment required", + "out of credits", + "out of quota", + "exceeded your current quota", + ) + _RETRYABLE_429_TEXT_MARKERS = ( + "rate limit", + "rate_limit", + "too many requests", + "retry after", + "try again in", + "temporarily unavailable", + "overloaded", + "concurrency limit", + ) _SENTINEL = object() @@ -209,6 +255,8 @@ class LLMProvider(ABC): if response.error_status_code is not None: status = int(response.error_status_code) + if status == 429: + return cls._is_retryable_429_response(response) if status in cls._RETRYABLE_STATUS_CODES or status >= 500: return True @@ -218,6 +266,61 @@ class LLMProvider(ABC): return cls._is_transient_error(response.content) + @staticmethod + def _normalize_error_token(value: Any) -> str | None: + if value is None: + return None + token = str(value).strip().lower() + return token or None + + @classmethod + def _extract_error_type_code(cls, payload: Any) -> tuple[str | None, str | None]: + data: dict[str, Any] | None = None + if isinstance(payload, dict): + data = payload + elif isinstance(payload, str): + text = payload.strip() + if text: + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, dict): + data = parsed + if not isinstance(data, dict): + return None, None + + error_obj = data.get("error") + type_value = data.get("type") + code_value = data.get("code") + if isinstance(error_obj, dict): + type_value = error_obj.get("type") or type_value + code_value = error_obj.get("code") or code_value + + return cls._normalize_error_token(type_value), cls._normalize_error_token(code_value) + + @classmethod + def _is_retryable_429_response(cls, response: LLMResponse) -> bool: + type_token = cls._normalize_error_token(response.error_type) + code_token = cls._normalize_error_token(response.error_code) + semantic_tokens = { + token for token in (type_token, code_token) + if token is not None + } + if any(token in cls._NON_RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens): + return False + + content = (response.content or "").lower() + if any(marker in content for marker in cls._NON_RETRYABLE_429_TEXT_MARKERS): + return False + + if any(token in cls._RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens): + return True + if any(marker in content for marker in cls._RETRYABLE_429_TEXT_MARKERS): + return True + # Unknown 429 defaults to WAIT+retry. + return True + @staticmethod def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None: """Replace image_url blocks with text placeholder. Returns None if no images found.""" diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 3120261d1..cb25e6f8c 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -621,6 +621,19 @@ class OpenAICompatProvider(LLMProvider): def _extract_error_metadata(cls, e: Exception) -> dict[str, Any]: response = getattr(e, "response", None) headers = getattr(response, "headers", None) + payload = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + if payload is None and response is not None: + response_json = getattr(response, "json", None) + if callable(response_json): + try: + payload = response_json() + except Exception: + payload = None + error_type, error_code = LLMProvider._extract_error_type_code(payload) status_code = getattr(e, "status_code", None) if status_code is None and response is not None: @@ -646,6 +659,8 @@ class OpenAICompatProvider(LLMProvider): return { "error_status_code": int(status_code) if status_code is not None else None, "error_kind": error_kind, + "error_type": error_type, + "error_code": error_code, "error_retry_after_s": cls._parse_retry_after_headers(headers), "error_should_retry": should_retry, } diff --git a/tests/providers/test_provider_error_metadata.py b/tests/providers/test_provider_error_metadata.py index b13c667de..27f0eb0f1 100644 --- a/tests/providers/test_provider_error_metadata.py +++ b/tests/providers/test_provider_error_metadata.py @@ -26,14 +26,16 @@ def test_openai_handle_error_extracts_structured_metadata() -> None: err.response = _fake_response( status_code=409, headers={"retry-after-ms": "250", "x-should-retry": "false"}, - text='{"error":"conflict"}', + text='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}', ) - err.body = {"error": "conflict"} + err.body = {"error": {"type": "rate_limit_exceeded", "code": "rate_limit_exceeded"}} response = OpenAICompatProvider._handle_error(err) assert response.finish_reason == "error" assert response.error_status_code == 409 + assert response.error_type == "rate_limit_exceeded" + assert response.error_code == "rate_limit_exceeded" assert response.error_retry_after_s == 0.25 assert response.error_should_retry is False @@ -58,11 +60,13 @@ def test_anthropic_error_response_extracts_structured_metadata() -> None: status_code=408, headers={"retry-after": "1.5", "x-should-retry": "true"}, ) + err.body = {"type": "error", "error": {"type": "rate_limit_error"}} response = AnthropicProvider._error_response(err) assert response.finish_reason == "error" assert response.error_status_code == 408 + assert response.error_type == "rate_limit_error" assert response.error_retry_after_s == 1.5 assert response.error_should_retry is True diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index 038473c69..ad8048162 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -297,6 +297,59 @@ async def test_chat_with_retry_retries_structured_status_code_without_keyword(mo assert delays == [1] +@pytest.mark.asyncio +async def test_chat_with_retry_stops_on_429_quota_exhausted(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content='{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}', + finish_reason="error", + error_status_code=429, + error_type="insufficient_quota", + error_code="insufficient_quota", + ), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.finish_reason == "error" + assert provider.calls == 1 + assert delays == [] + + +@pytest.mark.asyncio +async def test_chat_with_retry_retries_429_transient_rate_limit(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse( + content='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}', + finish_reason="error", + error_status_code=429, + error_type="rate_limit_exceeded", + error_code="rate_limit_exceeded", + error_retry_after_s=0.2, + ), + LLMResponse(content="ok"), + ]) + delays: list[float] = [] + + async def _fake_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) + + assert response.content == "ok" + assert provider.calls == 2 + assert delays == [0.2] + + @pytest.mark.asyncio async def test_chat_with_retry_retries_structured_timeout_kind(monkeypatch) -> None: provider = ScriptedProvider([ @@ -389,4 +442,3 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk assert response.content == "429 rate limit" assert provider.calls == 10 assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4] - From 401d1f57fa159ce0d1ca7c9e62ef59594e7a52ab Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 5 Apr 2026 22:04:12 +0800 Subject: [PATCH 12/85] fix(dream): allow LLM to retry on tool errors instead of failing immediately Dream Phase 2 uses fail_on_tool_error=True, which terminates the entire run on the first tool error (e.g. old_text not found in edit_file). Normal agent runs default to False so the LLM can self-correct and retry. Dream should behave the same way. --- nanobot/agent/memory.py | 2 +- tests/agent/test_dream.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 62de34bba..73010b13f 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -627,7 +627,7 @@ class Dream: model=self.model, max_iterations=self.max_iterations, max_tool_result_chars=self.max_tool_result_chars, - fail_on_tool_error=True, + fail_on_tool_error=False, )) logger.debug( "Dream Phase 2 complete: stop_reason={}, tool_events={}", diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 7898ea267..38faafa7d 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -72,7 +72,7 @@ class TestDreamRun: mock_runner.run.assert_called_once() spec = mock_runner.run.call_args[0][0] assert spec.max_iterations == 10 - assert spec.fail_on_tool_error is True + assert spec.fail_on_tool_error is False async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store): """Dream should advance the cursor after processing.""" From acf652358ca428ea264983c92e1c058f62ac4fe1 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 15:48:00 +0000 Subject: [PATCH 13/85] feat(dream): non-blocking /dream with progress feedback --- nanobot/command/builtin.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index a5629f66e..514ac1438 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -93,14 +93,30 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage: async def cmd_dream(ctx: CommandContext) -> OutboundMessage: """Manually trigger a Dream consolidation run.""" + import time + loop = ctx.loop - try: - did_work = await loop.dream.run() - content = "Dream completed." if did_work else "Dream: nothing to process." - except Exception as e: - content = f"Dream failed: {e}" + msg = ctx.msg + + async def _run_dream(): + t0 = time.monotonic() + try: + did_work = await loop.dream.run() + elapsed = time.monotonic() - t0 + if did_work: + content = f"Dream completed in {elapsed:.1f}s." + else: + content = "Dream: nothing to process." + except Exception as e: + elapsed = time.monotonic() - t0 + content = f"Dream failed after {elapsed:.1f}s: {e}" + await loop.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + )) + + asyncio.create_task(_run_dream()) return OutboundMessage( - channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, + channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...", ) From f422de8084f00ad70eecbdd3a008945ed7dea547 Mon Sep 17 00:00:00 2001 From: KimGLee <05_bolster_inkling@icloud.com> Date: Sun, 5 Apr 2026 11:50:16 +0800 Subject: [PATCH 14/85] fix(web-search): fix Jina search format and fallback --- nanobot/agent/tools/web.py | 9 ++-- tests/tools/test_web_search_tool.py | 67 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 9ac923050..b8aeab47b 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -8,7 +8,7 @@ import json import os import re from typing import TYPE_CHECKING, Any -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx from loguru import logger @@ -182,10 +182,10 @@ class WebSearchTool(Tool): return await self._search_duckduckgo(query, n) try: headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"} + encoded_query = quote(query, safe="") async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( - f"https://s.jina.ai/", - params={"q": query}, + f"https://s.jina.ai/{encoded_query}", headers=headers, timeout=15.0, ) @@ -197,7 +197,8 @@ class WebSearchTool(Tool): ] return _format_results(query, items, n) except Exception as e: - return f"Error: {e}" + logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e) + return await self._search_duckduckgo(query, n) async def _search_duckduckgo(self, query: str, n: int) -> str: try: diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 02bf44395..5445fc67b 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -160,3 +160,70 @@ async def test_searxng_invalid_url(): tool = _tool(provider="searxng", base_url="not-a-url") result = await tool.execute(query="test") assert "Error" in result + + +@pytest.mark.asyncio +async def test_jina_422_falls_back_to_duckduckgo(monkeypatch): + class MockDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] + + async def mock_get(self, url, **kw): + assert "s.jina.ai" in str(url) + raise httpx.HTTPStatusError( + "422 Unprocessable Entity", + request=httpx.Request("GET", str(url)), + response=httpx.Response(422, request=httpx.Request("GET", str(url))), + ) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + monkeypatch.setattr("ddgs.DDGS", MockDDGS) + + tool = _tool(provider="jina", api_key="jina-key") + result = await tool.execute(query="test") + assert "DuckDuckGo fallback" in result + + +@pytest.mark.asyncio +async def test_jina_search_uses_path_encoded_query(monkeypatch): + calls = {} + + async def mock_get(self, url, **kw): + calls["url"] = str(url) + calls["params"] = kw.get("params") + return _response(json={ + "data": [{"title": "Jina Result", "url": "https://jina.ai", "content": "AI search"}] + }) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + tool = _tool(provider="jina", api_key="jina-key") + await tool.execute(query="hello world") + assert calls["url"].rstrip("/") == "https://s.jina.ai/hello%20world" + assert calls["params"] in (None, {}) + + +@pytest.mark.asyncio +async def test_jina_422_falls_back_to_duckduckgo(monkeypatch): + class MockDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] + + async def mock_get(self, url, **kw): + raise httpx.HTTPStatusError( + "422 Unprocessable Entity", + request=httpx.Request("GET", str(url)), + response=httpx.Response(422, request=httpx.Request("GET", str(url))), + ) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + monkeypatch.setattr("ddgs.DDGS", MockDDGS) + + tool = _tool(provider="jina", api_key="jina-key") + result = await tool.execute(query="test") + assert "DuckDuckGo fallback" in result From 90caf5ce51ac64b9a25f611d96ced1833e641b23 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 17:51:17 +0000 Subject: [PATCH 15/85] test: remove duplicate test_jina_422_falls_back_to_duckduckgo The same test function name appeared twice; Python silently shadows the first definition so it never ran. Keep the version that also asserts the request URL contains "s.jina.ai". Made-with: Cursor --- tests/tools/test_web_search_tool.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 5445fc67b..2c6826dea 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -205,25 +205,3 @@ async def test_jina_search_uses_path_encoded_query(monkeypatch): assert calls["params"] in (None, {}) -@pytest.mark.asyncio -async def test_jina_422_falls_back_to_duckduckgo(monkeypatch): - class MockDDGS: - def __init__(self, **kw): - pass - - def text(self, query, max_results=5): - return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] - - async def mock_get(self, url, **kw): - raise httpx.HTTPStatusError( - "422 Unprocessable Entity", - request=httpx.Request("GET", str(url)), - response=httpx.Response(422, request=httpx.Request("GET", str(url))), - ) - - monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) - monkeypatch.setattr("ddgs.DDGS", MockDDGS) - - tool = _tool(provider="jina", api_key="jina-key") - result = await tool.execute(query="test") - assert "DuckDuckGo fallback" in result From 6bd2950b9937d4e693692221e96d2c262671b53f Mon Sep 17 00:00:00 2001 From: hoaresky Date: Sun, 5 Apr 2026 09:12:49 +0800 Subject: [PATCH 16/85] Fix: add asyncio timeout guard for DuckDuckGo search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDGS's internal `timeout=10` relies on `requests` read-timeout semantics, which only measure the gap between bytes — not total wall-clock time. When the underlying HTTP connection enters CLOSE-WAIT or the server dribbles data slowly, this timeout never fires, causing `ddgs.text` to hang indefinitely via `asyncio.to_thread`. Since `asyncio.to_thread` cannot cancel the underlying OS thread, the agent's session lock is never released, blocking all subsequent messages on the same session (observed: 8+ hours of unresponsiveness). Fix: - Add `timeout` field to `WebSearchConfig` (default: 30s, configurable via config.json or NANOBOT_TOOLS__WEB__SEARCH__TIMEOUT env var) - Wrap `asyncio.to_thread` with `asyncio.wait_for` to enforce a hard wall-clock deadline Closes #2804 Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/tools/web.py | 5 ++++- nanobot/config/schema.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index b8aeab47b..a6d7be983 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -207,7 +207,10 @@ class WebSearchTool(Tool): from ddgs import DDGS ddgs = DDGS(timeout=10) - raw = await asyncio.to_thread(ddgs.text, query, max_results=n) + raw = await asyncio.wait_for( + asyncio.to_thread(ddgs.text, query, max_results=n), + timeout=self.config.timeout, + ) if not raw: return f"No results for: {query}" items = [ diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0b5d6a817..47e35070c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -155,6 +155,7 @@ class WebSearchConfig(Base): api_key: str = "" base_url: str = "" # SearXNG base URL max_results: int = 5 + timeout: int = 30 # Wall-clock timeout (seconds) for search operations class WebToolsConfig(Base): From 4b4d8b506dcc6f303998d8774dd18b00bc64e612 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 18:18:59 +0000 Subject: [PATCH 17/85] test: add regression test for DuckDuckGo asyncio.wait_for timeout guard Made-with: Cursor --- tests/tools/test_web_search_tool.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 2c6826dea..e33dd7e6c 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -1,5 +1,7 @@ """Tests for multi-provider web search.""" +import asyncio + import httpx import pytest @@ -205,3 +207,25 @@ async def test_jina_search_uses_path_encoded_query(monkeypatch): assert calls["params"] in (None, {}) +@pytest.mark.asyncio +async def test_duckduckgo_timeout_returns_error(monkeypatch): + """asyncio.wait_for guard should fire when DDG search hangs.""" + import threading + gate = threading.Event() + + class HangingDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + gate.wait(timeout=10) + return [] + + monkeypatch.setattr("ddgs.DDGS", HangingDDGS) + tool = _tool(provider="duckduckgo") + tool.config.timeout = 0.2 + result = await tool.execute(query="test") + gate.set() + assert "Error" in result + + From 0d6bc7fc1135aced356fab26e98616323a5d84b5 Mon Sep 17 00:00:00 2001 From: Ilya Semenov Date: Sat, 4 Apr 2026 19:08:27 +0700 Subject: [PATCH 18/85] fix(telegram): support threads in DMs --- nanobot/channels/telegram.py | 10 +++++++--- tests/channels/test_telegram_channel.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 1aa0568c6..35f9ad620 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -599,11 +599,15 @@ class TelegramChannel(BaseChannel): return now = time.monotonic() + thread_kwargs = {} + if message_thread_id := meta.get("message_thread_id"): + thread_kwargs["message_thread_id"] = message_thread_id if buf.message_id is None: try: sent = await self._call_with_retry( self._app.bot.send_message, chat_id=int_chat_id, text=buf.text, + **thread_kwargs, ) buf.message_id = sent.message_id buf.last_edit = now @@ -651,9 +655,9 @@ class TelegramChannel(BaseChannel): @staticmethod def _derive_topic_session_key(message) -> str | None: - """Derive topic-scoped session key for non-private Telegram chats.""" + """Derive topic-scoped session key for Telegram chats with threads.""" message_thread_id = getattr(message, "message_thread_id", None) - if message.chat.type == "private" or message_thread_id is None: + if message_thread_id is None: return None return f"telegram:{message.chat_id}:topic:{message_thread_id}" @@ -815,7 +819,7 @@ class TelegramChannel(BaseChannel): return bool(bot_id and reply_user and reply_user.id == bot_id) def _remember_thread_context(self, message) -> None: - """Cache topic thread id by chat/message id for follow-up replies.""" + """Cache Telegram thread context by chat/message id for follow-up replies.""" message_thread_id = getattr(message, "message_thread_id", None) if message_thread_id is None: return diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 9584ad547..cb7f369d1 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -424,6 +424,23 @@ async def test_send_delta_incremental_edit_treats_not_modified_as_success() -> N assert channel._stream_bufs["123"].last_edit > 0.0 +@pytest.mark.asyncio +async def test_send_delta_initial_send_keeps_message_in_thread() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + await channel.send_delta( + "123", + "hello", + {"_stream_delta": True, "_stream_id": "s:0", "message_thread_id": 42}, + ) + + assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42 + + def test_derive_topic_session_key_uses_thread_id() -> None: message = SimpleNamespace( chat=SimpleNamespace(type="supergroup"), From bb9da29eff61b734e0b92099ef0ca2477341bcfa Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 18:41:28 +0000 Subject: [PATCH 19/85] test: add regression tests for private DM thread session key derivation Made-with: Cursor --- tests/channels/test_telegram_channel.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index cb7f369d1..1f25dcfa7 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -451,6 +451,27 @@ def test_derive_topic_session_key_uses_thread_id() -> None: assert TelegramChannel._derive_topic_session_key(message) == "telegram:-100123:topic:42" +def test_derive_topic_session_key_private_dm_thread() -> None: + """Private DM threads (Telegram Threaded Mode) must get their own session key.""" + message = SimpleNamespace( + chat=SimpleNamespace(type="private"), + chat_id=999, + message_thread_id=7, + ) + assert TelegramChannel._derive_topic_session_key(message) == "telegram:999:topic:7" + + +def test_derive_topic_session_key_none_without_thread() -> None: + """No thread id → no topic session key, regardless of chat type.""" + for chat_type in ("private", "supergroup", "group"): + message = SimpleNamespace( + chat=SimpleNamespace(type=chat_type), + chat_id=123, + message_thread_id=None, + ) + assert TelegramChannel._derive_topic_session_key(message) is None + + def test_get_extension_falls_back_to_original_filename() -> None: channel = TelegramChannel(TelegramConfig(), MessageBus()) From bcb83522358960f86fa03afa83eb1e46e7d8c97f Mon Sep 17 00:00:00 2001 From: Jack Lu <46274946+JackLuguibin@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:08:30 +0800 Subject: [PATCH 20/85] refactor(agent): streamline hook method calls and enhance error logging - Introduced a helper method `_for_each_hook_safe` to reduce code duplication in hook method implementations. - Updated error logging to include the method name for better traceability. - Improved the `SkillsLoader` class by adding a new method `_skill_entries_from_dir` to simplify skill listing logic. - Enhanced skill loading and filtering logic, ensuring workspace skills take precedence over built-in ones. - Added comprehensive tests for `SkillsLoader` to validate functionality and edge cases. --- nanobot/agent/hook.py | 33 ++-- nanobot/agent/skills.py | 197 +++++++++++----------- tests/agent/test_skills_loader.py | 252 ++++++++++++++++++++++++++++ tests/tools/test_tool_validation.py | 16 +- 4 files changed, 373 insertions(+), 125 deletions(-) create mode 100644 tests/agent/test_skills_loader.py diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index 97ec7a07d..827831ebd 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -67,40 +67,27 @@ class CompositeHook(AgentHook): def wants_streaming(self) -> bool: return any(h.wants_streaming() for h in self._hooks) - async def before_iteration(self, context: AgentHookContext) -> None: + async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None: for h in self._hooks: try: - await h.before_iteration(context) + await getattr(h, method_name)(*args, **kwargs) except Exception: - logger.exception("AgentHook.before_iteration error in {}", type(h).__name__) + logger.exception("AgentHook.{} error in {}", method_name, type(h).__name__) + + async def before_iteration(self, context: AgentHookContext) -> None: + await self._for_each_hook_safe("before_iteration", context) async def on_stream(self, context: AgentHookContext, delta: str) -> None: - for h in self._hooks: - try: - await h.on_stream(context, delta) - except Exception: - logger.exception("AgentHook.on_stream error in {}", type(h).__name__) + await self._for_each_hook_safe("on_stream", context, delta) async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: - for h in self._hooks: - try: - await h.on_stream_end(context, resuming=resuming) - except Exception: - logger.exception("AgentHook.on_stream_end error in {}", type(h).__name__) + await self._for_each_hook_safe("on_stream_end", context, resuming=resuming) async def before_execute_tools(self, context: AgentHookContext) -> None: - for h in self._hooks: - try: - await h.before_execute_tools(context) - except Exception: - logger.exception("AgentHook.before_execute_tools error in {}", type(h).__name__) + await self._for_each_hook_safe("before_execute_tools", context) async def after_iteration(self, context: AgentHookContext) -> None: - for h in self._hooks: - try: - await h.after_iteration(context) - except Exception: - logger.exception("AgentHook.after_iteration error in {}", type(h).__name__) + await self._for_each_hook_safe("after_iteration", context) def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None: for h in self._hooks: diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 9afee82f0..ca215cc96 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -9,6 +9,16 @@ from pathlib import Path # Default builtin skills directory (relative to this file) BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" +# Opening ---, YAML body (group 1), closing --- on its own line; supports CRLF. +_STRIP_SKILL_FRONTMATTER = re.compile( + r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", + re.DOTALL, +) + + +def _escape_xml(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + class SkillsLoader: """ @@ -23,6 +33,22 @@ class SkillsLoader: self.workspace_skills = workspace / "skills" self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR + def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: + if not base.exists(): + return [] + entries: list[dict[str, str]] = [] + for skill_dir in base.iterdir(): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if not skill_file.exists(): + continue + name = skill_dir.name + if skip_names is not None and name in skip_names: + continue + entries.append({"name": name, "path": str(skill_file), "source": source}) + return entries + def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]: """ List all available skills. @@ -33,27 +59,15 @@ class SkillsLoader: Returns: List of skill info dicts with 'name', 'path', 'source'. """ - skills = [] - - # Workspace skills (highest priority) - if self.workspace_skills.exists(): - for skill_dir in self.workspace_skills.iterdir(): - if skill_dir.is_dir(): - skill_file = skill_dir / "SKILL.md" - if skill_file.exists(): - skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"}) - - # Built-in skills + skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") + workspace_names = {entry["name"] for entry in skills} if self.builtin_skills and self.builtin_skills.exists(): - for skill_dir in self.builtin_skills.iterdir(): - if skill_dir.is_dir(): - skill_file = skill_dir / "SKILL.md" - if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills): - skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"}) + skills.extend( + self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names) + ) - # Filter by requirements if filter_unavailable: - return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))] + return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))] return skills def load_skill(self, name: str) -> str | None: @@ -66,17 +80,13 @@ class SkillsLoader: Returns: Skill content or None if not found. """ - # Check workspace first - workspace_skill = self.workspace_skills / name / "SKILL.md" - if workspace_skill.exists(): - return workspace_skill.read_text(encoding="utf-8") - - # Check built-in + roots = [self.workspace_skills] if self.builtin_skills: - builtin_skill = self.builtin_skills / name / "SKILL.md" - if builtin_skill.exists(): - return builtin_skill.read_text(encoding="utf-8") - + roots.append(self.builtin_skills) + for root in roots: + path = root / name / "SKILL.md" + if path.exists(): + return path.read_text(encoding="utf-8") return None def load_skills_for_context(self, skill_names: list[str]) -> str: @@ -89,14 +99,12 @@ class SkillsLoader: Returns: Formatted skills content. """ - parts = [] - for name in skill_names: - content = self.load_skill(name) - if content: - content = self._strip_frontmatter(content) - parts.append(f"### Skill: {name}\n\n{content}") - - return "\n\n---\n\n".join(parts) if parts else "" + parts = [ + f"### Skill: {name}\n\n{self._strip_frontmatter(markdown)}" + for name in skill_names + if (markdown := self.load_skill(name)) + ] + return "\n\n---\n\n".join(parts) def build_skills_summary(self) -> str: """ @@ -112,44 +120,36 @@ class SkillsLoader: if not all_skills: return "" - def escape_xml(s: str) -> str: - return s.replace("&", "&").replace("<", "<").replace(">", ">") - - lines = [""] - for s in all_skills: - name = escape_xml(s["name"]) - path = s["path"] - desc = escape_xml(self._get_skill_description(s["name"])) - skill_meta = self._get_skill_meta(s["name"]) - available = self._check_requirements(skill_meta) - - lines.append(f" ") - lines.append(f" {name}") - lines.append(f" {desc}") - lines.append(f" {path}") - - # Show missing requirements for unavailable skills + lines: list[str] = [""] + for entry in all_skills: + skill_name = entry["name"] + meta = self._get_skill_meta(skill_name) + available = self._check_requirements(meta) + lines.extend( + [ + f' ', + f" {_escape_xml(skill_name)}", + f" {_escape_xml(self._get_skill_description(skill_name))}", + f" {entry['path']}", + ] + ) if not available: - missing = self._get_missing_requirements(skill_meta) + missing = self._get_missing_requirements(meta) if missing: - lines.append(f" {escape_xml(missing)}") - + lines.append(f" {_escape_xml(missing)}") lines.append(" ") lines.append("") - return "\n".join(lines) def _get_missing_requirements(self, skill_meta: dict) -> str: """Get a description of missing requirements.""" - missing = [] requires = skill_meta.get("requires", {}) - for b in requires.get("bins", []): - if not shutil.which(b): - missing.append(f"CLI: {b}") - for env in requires.get("env", []): - if not os.environ.get(env): - missing.append(f"ENV: {env}") - return ", ".join(missing) + required_bins = requires.get("bins", []) + required_env_vars = requires.get("env", []) + return ", ".join( + [f"CLI: {command_name}" for command_name in required_bins if not shutil.which(command_name)] + + [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)] + ) def _get_skill_description(self, name: str) -> str: """Get the description of a skill from its frontmatter.""" @@ -160,30 +160,32 @@ class SkillsLoader: def _strip_frontmatter(self, content: str) -> str: """Remove YAML frontmatter from markdown content.""" - if content.startswith("---"): - match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL) - if match: - return content[match.end():].strip() + if not content.startswith("---"): + return content + match = _STRIP_SKILL_FRONTMATTER.match(content) + if match: + return content[match.end():].strip() return content def _parse_nanobot_metadata(self, raw: str) -> dict: """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" try: data = json.loads(raw) - return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} except (json.JSONDecodeError, TypeError): return {} + if not isinstance(data, dict): + return {} + payload = data.get("nanobot", data.get("openclaw", {})) + return payload if isinstance(payload, dict) else {} def _check_requirements(self, skill_meta: dict) -> bool: """Check if skill requirements are met (bins, env vars).""" requires = skill_meta.get("requires", {}) - for b in requires.get("bins", []): - if not shutil.which(b): - return False - for env in requires.get("env", []): - if not os.environ.get(env): - return False - return True + required_bins = requires.get("bins", []) + required_env_vars = requires.get("env", []) + return all(shutil.which(cmd) for cmd in required_bins) and all( + os.environ.get(var) for var in required_env_vars + ) def _get_skill_meta(self, name: str) -> dict: """Get nanobot metadata for a skill (cached in frontmatter).""" @@ -192,13 +194,15 @@ class SkillsLoader: def get_always_skills(self) -> list[str]: """Get skills marked as always=true that meet requirements.""" - result = [] - for s in self.list_skills(filter_unavailable=True): - meta = self.get_skill_metadata(s["name"]) or {} - skill_meta = self._parse_nanobot_metadata(meta.get("metadata", "")) - if skill_meta.get("always") or meta.get("always"): - result.append(s["name"]) - return result + return [ + entry["name"] + for entry in self.list_skills(filter_unavailable=True) + if (meta := self.get_skill_metadata(entry["name"]) or {}) + and ( + self._parse_nanobot_metadata(meta.get("metadata", "")).get("always") + or meta.get("always") + ) + ] def get_skill_metadata(self, name: str) -> dict | None: """ @@ -211,18 +215,15 @@ class SkillsLoader: Metadata dict or None. """ content = self.load_skill(name) - if not content: + if not content or not content.startswith("---"): return None - - if content.startswith("---"): - match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) - if match: - # Simple YAML parsing - metadata = {} - for line in match.group(1).split("\n"): - if ":" in line: - key, value = line.split(":", 1) - metadata[key.strip()] = value.strip().strip('"\'') - return metadata - - return None + match = _STRIP_SKILL_FRONTMATTER.match(content) + if not match: + return None + metadata: dict[str, str] = {} + for line in match.group(1).splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip().strip('"\'') + return metadata diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py new file mode 100644 index 000000000..46923c806 --- /dev/null +++ b/tests/agent/test_skills_loader.py @@ -0,0 +1,252 @@ +"""Tests for nanobot.agent.skills.SkillsLoader.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from nanobot.agent.skills import SkillsLoader + + +def _write_skill( + base: Path, + name: str, + *, + metadata_json: dict | None = None, + body: str = "# Skill\n", +) -> Path: + """Create ``base / name / SKILL.md`` with optional nanobot metadata JSON.""" + skill_dir = base / name + skill_dir.mkdir(parents=True) + lines = ["---"] + if metadata_json is not None: + payload = json.dumps({"nanobot": metadata_json}, separators=(",", ":")) + lines.append(f'metadata: {payload}') + lines.extend(["---", "", body]) + path = skill_dir / "SKILL.md" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def test_list_skills_empty_when_skills_dir_missing(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + builtin = tmp_path / "builtin" + builtin.mkdir() + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + assert loader.list_skills(filter_unavailable=False) == [] + + +def test_list_skills_empty_when_skills_dir_exists_but_empty(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + (workspace / "skills").mkdir(parents=True) + builtin = tmp_path / "builtin" + builtin.mkdir() + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + assert loader.list_skills(filter_unavailable=False) == [] + + +def test_list_skills_workspace_entry_shape_and_source(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + skill_path = _write_skill(skills_root, "alpha", body="# Alpha") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = loader.list_skills(filter_unavailable=False) + assert entries == [ + {"name": "alpha", "path": str(skill_path), "source": "workspace"}, + ] + + +def test_list_skills_skips_non_directories_and_missing_skill_md(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + (skills_root / "not_a_dir.txt").write_text("x", encoding="utf-8") + (skills_root / "no_skill_md").mkdir() + ok_path = _write_skill(skills_root, "ok", body="# Ok") + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = loader.list_skills(filter_unavailable=False) + names = {entry["name"] for entry in entries} + assert names == {"ok"} + assert entries[0]["path"] == str(ok_path) + + +def test_list_skills_workspace_shadows_builtin_same_name(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + ws_path = _write_skill(ws_skills, "dup", body="# Workspace wins") + + builtin = tmp_path / "builtin" + _write_skill(builtin, "dup", body="# Builtin") + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = loader.list_skills(filter_unavailable=False) + assert len(entries) == 1 + assert entries[0]["source"] == "workspace" + assert entries[0]["path"] == str(ws_path) + + +def test_list_skills_merges_workspace_and_builtin(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + ws_path = _write_skill(ws_skills, "ws_only", body="# W") + builtin = tmp_path / "builtin" + bi_path = _write_skill(builtin, "bi_only", body="# B") + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = sorted(loader.list_skills(filter_unavailable=False), key=lambda item: item["name"]) + assert entries == [ + {"name": "bi_only", "path": str(bi_path), "source": "builtin"}, + {"name": "ws_only", "path": str(ws_path), "source": "workspace"}, + ] + + +def test_list_skills_builtin_omitted_when_dir_missing(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + ws_path = _write_skill(ws_skills, "solo", body="# S") + missing_builtin = tmp_path / "no_such_builtin" + + loader = SkillsLoader(workspace, builtin_skills_dir=missing_builtin) + entries = loader.list_skills(filter_unavailable=False) + assert entries == [{"name": "solo", "path": str(ws_path), "source": "workspace"}] + + +def test_list_skills_filter_unavailable_excludes_unmet_bin_requirement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + _write_skill( + skills_root, + "needs_bin", + metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}}, + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + def fake_which(cmd: str) -> str | None: + if cmd == "nanobot_test_fake_binary": + return None + return "/usr/bin/true" + + monkeypatch.setattr("nanobot.agent.skills.shutil.which", fake_which) + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + assert loader.list_skills(filter_unavailable=True) == [] + + +def test_list_skills_filter_unavailable_includes_when_bin_requirement_met( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + skill_path = _write_skill( + skills_root, + "has_bin", + metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}}, + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + def fake_which(cmd: str) -> str | None: + if cmd == "nanobot_test_fake_binary": + return "/fake/nanobot_test_fake_binary" + return None + + monkeypatch.setattr("nanobot.agent.skills.shutil.which", fake_which) + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = loader.list_skills(filter_unavailable=True) + assert entries == [ + {"name": "has_bin", "path": str(skill_path), "source": "workspace"}, + ] + + +def test_list_skills_filter_unavailable_false_keeps_unmet_requirements( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + skill_path = _write_skill( + skills_root, + "blocked", + metadata_json={"requires": {"bins": ["nanobot_test_fake_binary"]}}, + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + monkeypatch.setattr("nanobot.agent.skills.shutil.which", lambda _cmd: None) + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + entries = loader.list_skills(filter_unavailable=False) + assert entries == [ + {"name": "blocked", "path": str(skill_path), "source": "workspace"}, + ] + + +def test_list_skills_filter_unavailable_excludes_unmet_env_requirement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + _write_skill( + skills_root, + "needs_env", + metadata_json={"requires": {"env": ["NANOBOT_SKILLS_TEST_ENV_VAR"]}}, + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + monkeypatch.delenv("NANOBOT_SKILLS_TEST_ENV_VAR", raising=False) + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + assert loader.list_skills(filter_unavailable=True) == [] + + +def test_list_skills_openclaw_metadata_parsed_for_requirements( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + skill_dir = skills_root / "openclaw_skill" + skill_dir.mkdir(parents=True) + skill_path = skill_dir / "SKILL.md" + oc_payload = json.dumps({"openclaw": {"requires": {"bins": ["nanobot_oc_bin"]}}}, separators=(",", ":")) + skill_path.write_text( + "\n".join(["---", f"metadata: {oc_payload}", "---", "", "# OC"]), + encoding="utf-8", + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + monkeypatch.setattr("nanobot.agent.skills.shutil.which", lambda _cmd: None) + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + assert loader.list_skills(filter_unavailable=True) == [] + + monkeypatch.setattr( + "nanobot.agent.skills.shutil.which", + lambda cmd: "/x" if cmd == "nanobot_oc_bin" else None, + ) + entries = loader.list_skills(filter_unavailable=True) + assert entries == [ + {"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"}, + ] diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index e56f93185..072623db8 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -1,3 +1,6 @@ +import shlex +import subprocess +import sys from typing import Any from nanobot.agent.tools import ( @@ -546,10 +549,15 @@ async def test_exec_head_tail_truncation() -> None: """Long output should preserve both head and tail.""" tool = ExecTool() # Generate output that exceeds _MAX_OUTPUT (10_000 chars) - # Use python to generate output to avoid command line length limits - result = await tool.execute( - command="python -c \"print('A' * 6000 + '\\n' + 'B' * 6000)\"" - ) + # Use current interpreter (PATH may not have `python`). ExecTool uses + # create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe + # rules, so list2cmdline is appropriate there. + script = "print('A' * 6000 + '\\n' + 'B' * 6000)" + if sys.platform == "win32": + command = subprocess.list2cmdline([sys.executable, "-c", script]) + else: + command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}" + result = await tool.execute(command=command) assert "chars truncated" in result # Head portion should start with As assert result.startswith("A") From cef0f3f988372caee95b1436df35bcfae1ccda24 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:03:06 +0000 Subject: [PATCH 21/85] refactor: replace podman-seccomp.json with minimal cap_add, harden bwrap, add sandbox tests --- docker-compose.yml | 6 +- nanobot/agent/tools/sandbox.py | 2 +- podman-seccomp.json | 1129 -------------------------------- tests/tools/test_sandbox.py | 105 +++ 4 files changed, 111 insertions(+), 1131 deletions(-) delete mode 100644 podman-seccomp.json create mode 100644 tests/tools/test_sandbox.py diff --git a/docker-compose.yml b/docker-compose.yml index 88b9f4d07..2b2c9acd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,13 @@ x-common-config: &common-config dockerfile: Dockerfile volumes: - ~/.nanobot:/home/nanobot/.nanobot + cap_drop: + - ALL + cap_add: + - SYS_ADMIN security_opt: - apparmor=unconfined - - seccomp=./podman-seccomp.json + - seccomp=unconfined services: nanobot-gateway: diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py index 67818ec00..25f869daa 100644 --- a/nanobot/agent/tools/sandbox.py +++ b/nanobot/agent/tools/sandbox.py @@ -25,7 +25,7 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str: optional = ["/bin", "/lib", "/lib64", "/etc/alternatives", "/etc/ssl/certs", "/etc/resolv.conf", "/etc/ld.so.cache"] - args = ["bwrap"] + args = ["bwrap", "--new-session", "--die-with-parent"] for p in required: args += ["--ro-bind", p, p] for p in optional: args += ["--ro-bind-try", p, p] args += [ diff --git a/podman-seccomp.json b/podman-seccomp.json deleted file mode 100644 index 92d882b5c..000000000 --- a/podman-seccomp.json +++ /dev/null @@ -1,1129 +0,0 @@ -{ - "defaultAction": "SCMP_ACT_ERRNO", - "defaultErrnoRet": 38, - "defaultErrno": "ENOSYS", - "archMap": [ - { - "architecture": "SCMP_ARCH_X86_64", - "subArchitectures": [ - "SCMP_ARCH_X86", - "SCMP_ARCH_X32" - ] - }, - { - "architecture": "SCMP_ARCH_AARCH64", - "subArchitectures": [ - "SCMP_ARCH_ARM" - ] - }, - { - "architecture": "SCMP_ARCH_MIPS64", - "subArchitectures": [ - "SCMP_ARCH_MIPS", - "SCMP_ARCH_MIPS64N32" - ] - }, - { - "architecture": "SCMP_ARCH_MIPS64N32", - "subArchitectures": [ - "SCMP_ARCH_MIPS", - "SCMP_ARCH_MIPS64" - ] - }, - { - "architecture": "SCMP_ARCH_MIPSEL64", - "subArchitectures": [ - "SCMP_ARCH_MIPSEL", - "SCMP_ARCH_MIPSEL64N32" - ] - }, - { - "architecture": "SCMP_ARCH_MIPSEL64N32", - "subArchitectures": [ - "SCMP_ARCH_MIPSEL", - "SCMP_ARCH_MIPSEL64" - ] - }, - { - "architecture": "SCMP_ARCH_S390X", - "subArchitectures": [ - "SCMP_ARCH_S390" - ] - } - ], - "syscalls": [ - { - "names": [ - "bdflush", - "cachestat", - "futex_requeue", - "futex_wait", - "futex_waitv", - "futex_wake", - "io_pgetevents", - "io_pgetevents_time64", - "kexec_file_load", - "kexec_load", - "map_shadow_stack", - "migrate_pages", - "move_pages", - "nfsservctl", - "nice", - "oldfstat", - "oldlstat", - "oldolduname", - "oldstat", - "olduname", - "pciconfig_iobase", - "pciconfig_read", - "pciconfig_write", - "sgetmask", - "ssetmask", - "swapoff", - "swapon", - "syscall", - "sysfs", - "uselib", - "userfaultfd", - "ustat", - "vm86", - "vm86old", - "vmsplice" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": {}, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "_llseek", - "_newselect", - "accept", - "accept4", - "access", - "adjtimex", - "alarm", - "bind", - "brk", - "capget", - "capset", - "chdir", - "chmod", - "chown", - "chown32", - "clock_adjtime", - "clock_adjtime64", - "clock_getres", - "clock_getres_time64", - "clock_gettime", - "clock_gettime64", - "clock_nanosleep", - "clock_nanosleep_time64", - "clone", - "clone3", - "close", - "close_range", - "connect", - "copy_file_range", - "creat", - "dup", - "dup2", - "dup3", - "epoll_create", - "epoll_create1", - "epoll_ctl", - "epoll_ctl_old", - "epoll_pwait", - "epoll_pwait2", - "epoll_wait", - "epoll_wait_old", - "eventfd", - "eventfd2", - "execve", - "execveat", - "exit", - "exit_group", - "faccessat", - "faccessat2", - "fadvise64", - "fadvise64_64", - "fallocate", - "fanotify_init", - "fanotify_mark", - "fchdir", - "fchmod", - "fchmodat", - "fchmodat2", - "fchown", - "fchown32", - "fchownat", - "fcntl", - "fcntl64", - "fdatasync", - "fgetxattr", - "flistxattr", - "flock", - "fork", - "fremovexattr", - "fsconfig", - "fsetxattr", - "fsmount", - "fsopen", - "fspick", - "fstat", - "fstat64", - "fstatat64", - "fstatfs", - "fstatfs64", - "fsync", - "ftruncate", - "ftruncate64", - "futex", - "futex_time64", - "futimesat", - "get_mempolicy", - "get_robust_list", - "get_thread_area", - "getcpu", - "getcwd", - "getdents", - "getdents64", - "getegid", - "getegid32", - "geteuid", - "geteuid32", - "getgid", - "getgid32", - "getgroups", - "getgroups32", - "getitimer", - "getpeername", - "getpgid", - "getpgrp", - "getpid", - "getppid", - "getpriority", - "getrandom", - "getresgid", - "getresgid32", - "getresuid", - "getresuid32", - "getrlimit", - "getrusage", - "getsid", - "getsockname", - "getsockopt", - "gettid", - "gettimeofday", - "getuid", - "getuid32", - "getxattr", - "inotify_add_watch", - "inotify_init", - "inotify_init1", - "inotify_rm_watch", - "io_cancel", - "io_destroy", - "io_getevents", - "io_setup", - "io_submit", - "ioctl", - "ioprio_get", - "ioprio_set", - "ipc", - "keyctl", - "kill", - "landlock_add_rule", - "landlock_create_ruleset", - "landlock_restrict_self", - "lchown", - "lchown32", - "lgetxattr", - "link", - "linkat", - "listen", - "listxattr", - "llistxattr", - "lremovexattr", - "lseek", - "lsetxattr", - "lstat", - "lstat64", - "madvise", - "mbind", - "membarrier", - "memfd_create", - "memfd_secret", - "mincore", - "mkdir", - "mkdirat", - "mknod", - "mknodat", - "mlock", - "mlock2", - "mlockall", - "mmap", - "mmap2", - "mount", - "mount_setattr", - "move_mount", - "mprotect", - "mq_getsetattr", - "mq_notify", - "mq_open", - "mq_timedreceive", - "mq_timedreceive_time64", - "mq_timedsend", - "mq_timedsend_time64", - "mq_unlink", - "mremap", - "msgctl", - "msgget", - "msgrcv", - "msgsnd", - "msync", - "munlock", - "munlockall", - "munmap", - "name_to_handle_at", - "nanosleep", - "newfstatat", - "open", - "open_tree", - "openat", - "openat2", - "pause", - "pidfd_getfd", - "pidfd_open", - "pidfd_send_signal", - "pipe", - "pipe2", - "pivot_root", - "pkey_alloc", - "pkey_free", - "pkey_mprotect", - "poll", - "ppoll", - "ppoll_time64", - "prctl", - "pread64", - "preadv", - "preadv2", - "prlimit64", - "process_mrelease", - "process_vm_readv", - "process_vm_writev", - "pselect6", - "pselect6_time64", - "ptrace", - "pwrite64", - "pwritev", - "pwritev2", - "read", - "readahead", - "readlink", - "readlinkat", - "readv", - "reboot", - "recv", - "recvfrom", - "recvmmsg", - "recvmmsg_time64", - "recvmsg", - "remap_file_pages", - "removexattr", - "rename", - "renameat", - "renameat2", - "restart_syscall", - "rmdir", - "rseq", - "rt_sigaction", - "rt_sigpending", - "rt_sigprocmask", - "rt_sigqueueinfo", - "rt_sigreturn", - "rt_sigsuspend", - "rt_sigtimedwait", - "rt_sigtimedwait_time64", - "rt_tgsigqueueinfo", - "sched_get_priority_max", - "sched_get_priority_min", - "sched_getaffinity", - "sched_getattr", - "sched_getparam", - "sched_getscheduler", - "sched_rr_get_interval", - "sched_rr_get_interval_time64", - "sched_setaffinity", - "sched_setattr", - "sched_setparam", - "sched_setscheduler", - "sched_yield", - "seccomp", - "select", - "semctl", - "semget", - "semop", - "semtimedop", - "semtimedop_time64", - "send", - "sendfile", - "sendfile64", - "sendmmsg", - "sendmsg", - "sendto", - "set_mempolicy", - "set_robust_list", - "set_thread_area", - "set_tid_address", - "setfsgid", - "setfsgid32", - "setfsuid", - "setfsuid32", - "setgid", - "setgid32", - "setgroups", - "setgroups32", - "setitimer", - "setns", - "setpgid", - "setpriority", - "setregid", - "setregid32", - "setresgid", - "setresgid32", - "setresuid", - "setresuid32", - "setreuid", - "setreuid32", - "setrlimit", - "setsid", - "setsockopt", - "setuid", - "setuid32", - "setxattr", - "shmat", - "shmctl", - "shmdt", - "shmget", - "shutdown", - "sigaltstack", - "signal", - "signalfd", - "signalfd4", - "sigprocmask", - "sigreturn", - "socketcall", - "socketpair", - "splice", - "stat", - "stat64", - "statfs", - "statfs64", - "statx", - "symlink", - "symlinkat", - "sync", - "sync_file_range", - "syncfs", - "sysinfo", - "syslog", - "tee", - "tgkill", - "time", - "timer_create", - "timer_delete", - "timer_getoverrun", - "timer_gettime", - "timer_gettime64", - "timer_settime", - "timer_settime64", - "timerfd_create", - "timerfd_gettime", - "timerfd_gettime64", - "timerfd_settime", - "timerfd_settime64", - "times", - "tkill", - "truncate", - "truncate64", - "ugetrlimit", - "umask", - "umount", - "umount2", - "uname", - "unlink", - "unlinkat", - "unshare", - "utime", - "utimensat", - "utimensat_time64", - "utimes", - "vfork", - "wait4", - "waitid", - "waitpid", - "write", - "writev" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 0, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 8, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 131072, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 131080, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 4294967295, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "sync_file_range2", - "swapcontext" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "ppc64le" - ] - }, - "excludes": {} - }, - { - "names": [ - "arm_fadvise64_64", - "arm_sync_file_range", - "breakpoint", - "cacheflush", - "set_tls", - "sync_file_range2" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "arm", - "arm64" - ] - }, - "excludes": {} - }, - { - "names": [ - "arch_prctl" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "amd64", - "x32" - ] - }, - "excludes": {} - }, - { - "names": [ - "modify_ldt" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "amd64", - "x32", - "x86" - ] - }, - "excludes": {} - }, - { - "names": [ - "s390_pci_mmio_read", - "s390_pci_mmio_write", - "s390_runtime_instr" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "s390", - "s390x" - ] - }, - "excludes": {} - }, - { - "names": [ - "riscv_flush_icache" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "riscv64" - ] - }, - "excludes": {} - }, - { - "names": [ - "open_by_handle_at" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_DAC_READ_SEARCH" - ] - }, - "excludes": {} - }, - { - "names": [ - "open_by_handle_at" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_DAC_READ_SEARCH" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "bpf", - "lookup_dcookie", - "quotactl", - "quotactl_fd", - "setdomainname", - "sethostname", - "setns" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_ADMIN" - ] - }, - "excludes": {} - }, - { - "names": [ - "lookup_dcookie", - "perf_event_open", - "quotactl", - "quotactl_fd", - "setdomainname", - "sethostname", - "setns" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "chroot" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_CHROOT" - ] - }, - "excludes": {} - }, - { - "names": [ - "chroot" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_CHROOT" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "delete_module", - "finit_module", - "init_module", - "query_module" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_MODULE" - ] - }, - "excludes": {} - }, - { - "names": [ - "delete_module", - "finit_module", - "init_module", - "query_module" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_MODULE" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "acct" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_PACCT" - ] - }, - "excludes": {} - }, - { - "names": [ - "acct" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_PACCT" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "kcmp", - "process_madvise" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_PTRACE" - ] - }, - "excludes": {} - }, - { - "names": [ - "kcmp", - "process_madvise" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_PTRACE" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "ioperm", - "iopl" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_RAWIO" - ] - }, - "excludes": {} - }, - { - "names": [ - "ioperm", - "iopl" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_RAWIO" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "clock_settime", - "clock_settime64", - "settimeofday", - "stime" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_TIME" - ] - }, - "excludes": {} - }, - { - "names": [ - "clock_settime", - "clock_settime64", - "settimeofday", - "stime" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_TIME" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "vhangup" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_TTY_CONFIG" - ] - }, - "excludes": {} - }, - { - "names": [ - "vhangup" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_TTY_CONFIG" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ERRNO", - "args": [ - { - "index": 0, - "value": 16, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - }, - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - }, - "errnoRet": 22, - "errno": "EINVAL" - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 16, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": null, - "comment": "", - "includes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - }, - "excludes": {} - }, - { - "names": [ - "bpf" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN", - "CAP_BPF" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "bpf" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_BPF" - ] - }, - "excludes": {} - }, - { - "names": [ - "perf_event_open" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN", - "CAP_BPF" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "perf_event_open" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_PERFMON" - ] - }, - "excludes": {} - } - ] -} \ No newline at end of file diff --git a/tests/tools/test_sandbox.py b/tests/tools/test_sandbox.py new file mode 100644 index 000000000..315bcf7c8 --- /dev/null +++ b/tests/tools/test_sandbox.py @@ -0,0 +1,105 @@ +"""Tests for nanobot.agent.tools.sandbox.""" + +import shlex + +import pytest + +from nanobot.agent.tools.sandbox import wrap_command + + +def _parse(cmd: str) -> list[str]: + """Split a wrapped command back into tokens for assertion.""" + return shlex.split(cmd) + + +class TestBwrapBackend: + def test_basic_structure(self, tmp_path): + ws = str(tmp_path / "project") + result = wrap_command("bwrap", "echo hi", ws, ws) + tokens = _parse(result) + + assert tokens[0] == "bwrap" + assert "--new-session" in tokens + assert "--die-with-parent" in tokens + assert "--ro-bind" in tokens + assert "--proc" in tokens + assert "--dev" in tokens + assert "--tmpfs" in tokens + + sep = tokens.index("--") + assert tokens[sep + 1:] == ["sh", "-c", "echo hi"] + + def test_workspace_bind_mounted_rw(self, tmp_path): + ws = str(tmp_path / "project") + result = wrap_command("bwrap", "ls", ws, ws) + tokens = _parse(result) + + bind_idx = [i for i, t in enumerate(tokens) if t == "--bind"] + assert any(tokens[i + 1] == ws and tokens[i + 2] == ws for i in bind_idx) + + def test_parent_dir_masked_with_tmpfs(self, tmp_path): + ws = tmp_path / "project" + result = wrap_command("bwrap", "ls", str(ws), str(ws)) + tokens = _parse(result) + + tmpfs_indices = [i for i, t in enumerate(tokens) if t == "--tmpfs"] + tmpfs_targets = {tokens[i + 1] for i in tmpfs_indices} + assert str(ws.parent) in tmpfs_targets + + def test_cwd_inside_workspace(self, tmp_path): + ws = tmp_path / "project" + sub = ws / "src" / "lib" + result = wrap_command("bwrap", "pwd", str(ws), str(sub)) + tokens = _parse(result) + + chdir_idx = tokens.index("--chdir") + assert tokens[chdir_idx + 1] == str(sub) + + def test_cwd_outside_workspace_falls_back(self, tmp_path): + ws = tmp_path / "project" + outside = tmp_path / "other" + result = wrap_command("bwrap", "pwd", str(ws), str(outside)) + tokens = _parse(result) + + chdir_idx = tokens.index("--chdir") + assert tokens[chdir_idx + 1] == str(ws.resolve()) + + def test_command_with_special_characters(self, tmp_path): + ws = str(tmp_path / "project") + cmd = "echo 'hello world' && cat \"file with spaces.txt\"" + result = wrap_command("bwrap", cmd, ws, ws) + tokens = _parse(result) + + sep = tokens.index("--") + assert tokens[sep + 1:] == ["sh", "-c", cmd] + + def test_system_dirs_ro_bound(self, tmp_path): + ws = str(tmp_path / "project") + result = wrap_command("bwrap", "ls", ws, ws) + tokens = _parse(result) + + ro_bind_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind"] + ro_targets = {tokens[i + 1] for i in ro_bind_indices} + assert "/usr" in ro_targets + + def test_optional_dirs_use_ro_bind_try(self, tmp_path): + ws = str(tmp_path / "project") + result = wrap_command("bwrap", "ls", ws, ws) + tokens = _parse(result) + + try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"] + try_targets = {tokens[i + 1] for i in try_indices} + assert "/bin" in try_targets + assert "/etc/ssl/certs" in try_targets + + +class TestUnknownBackend: + def test_raises_value_error(self, tmp_path): + ws = str(tmp_path / "project") + with pytest.raises(ValueError, match="Unknown sandbox backend"): + wrap_command("nonexistent", "ls", ws, ws) + + def test_empty_string_raises(self, tmp_path): + ws = str(tmp_path / "project") + with pytest.raises(ValueError): + wrap_command("", "ls", ws, ws) From 9f96be6e9bd0bdef7980d13affa092dffac7d484 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:08:38 +0000 Subject: [PATCH 22/85] fix(sandbox): mount media directory read-only inside bwrap sandbox --- nanobot/agent/tools/sandbox.py | 8 +++++++- tests/tools/test_sandbox.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py index 25f869daa..459ce16a3 100644 --- a/nanobot/agent/tools/sandbox.py +++ b/nanobot/agent/tools/sandbox.py @@ -8,14 +8,19 @@ and register it in _BACKENDS below. import shlex from pathlib import Path +from nanobot.config.paths import get_media_dir + def _bwrap(command: str, workspace: str, cwd: str) -> str: """Wrap command in a bubblewrap sandbox (requires bwrap in container). Only the workspace is bind-mounted read-write; its parent dir (which holds - config.json) is hidden behind a fresh tmpfs. + config.json) is hidden behind a fresh tmpfs. The media directory is + bind-mounted read-only so exec commands can read uploaded attachments. """ ws = Path(workspace).resolve() + media = get_media_dir().resolve() + try: sandbox_cwd = str(ws / Path(cwd).resolve().relative_to(ws)) except ValueError: @@ -33,6 +38,7 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str: "--tmpfs", str(ws.parent), # mask config dir "--dir", str(ws), # recreate workspace mount point "--bind", str(ws), str(ws), + "--ro-bind-try", str(media), str(media), # read-only access to media "--chdir", sandbox_cwd, "--", "sh", "-c", command, ] diff --git a/tests/tools/test_sandbox.py b/tests/tools/test_sandbox.py index 315bcf7c8..82232d83e 100644 --- a/tests/tools/test_sandbox.py +++ b/tests/tools/test_sandbox.py @@ -92,6 +92,22 @@ class TestBwrapBackend: assert "/bin" in try_targets assert "/etc/ssl/certs" in try_targets + def test_media_dir_ro_bind(self, tmp_path, monkeypatch): + """Media directory should be read-only mounted inside the sandbox.""" + fake_media = tmp_path / "media" + fake_media.mkdir() + monkeypatch.setattr( + "nanobot.agent.tools.sandbox.get_media_dir", + lambda: fake_media, + ) + ws = str(tmp_path / "project") + result = wrap_command("bwrap", "ls", ws, ws) + tokens = _parse(result) + + try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"] + try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in try_indices} + assert (str(fake_media), str(fake_media)) in try_pairs + class TestUnknownBackend: def test_raises_value_error(self, tmp_path): From 9823130432de872e9b1f63e5e1505845683e40d8 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:28:46 +0000 Subject: [PATCH 23/85] docs: clarify bwrap sandbox is Linux-only --- README.md | 5 ++++- SECURITY.md | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b62079351..3735addda 100644 --- a/README.md +++ b/README.md @@ -1434,16 +1434,19 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ### Security > [!TIP] -> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. +> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent. > In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`. | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | +| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). | | `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. | +**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation). + ### Timezone diff --git a/SECURITY.md b/SECURITY.md index d98adb6e9..8e65d4042 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,6 +64,7 @@ chmod 600 ~/.nanobot/config.json The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should: +- ✅ **Enable the bwrap sandbox** (`"tools.exec.sandbox": "bwrap"`) for kernel-level isolation (Linux only) - ✅ Review all tool usage in agent logs - ✅ Understand what commands the agent is running - ✅ Use a dedicated user account with limited privileges @@ -71,6 +72,19 @@ The `exec` tool can execute shell commands. While dangerous command patterns are - ❌ Don't disable security checks - ❌ Don't run on systems with sensitive data without careful review +**Exec sandbox (bwrap):** + +On Linux, set `"tools.exec.sandbox": "bwrap"` to wrap every shell command in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox. This uses Linux kernel namespaces to restrict what the process can see: + +- Workspace directory → **read-write** (agent works normally) +- Media directory → **read-only** (can read uploaded attachments) +- System directories (`/usr`, `/bin`, `/lib`) → **read-only** (commands still work) +- Config files and API keys (`~/.nanobot/config.json`) → **hidden** (masked by tmpfs) + +Requires `bwrap` installed (`apt install bubblewrap`). Pre-installed in the official Docker image. **Not available on macOS or Windows** — bubblewrap depends on Linux kernel namespaces. + +Enabling the sandbox also automatically activates `restrictToWorkspace` for file tools. + **Blocked patterns:** - `rm -rf /` - Root filesystem deletion - Fork bombs @@ -82,6 +96,7 @@ The `exec` tool can execute shell commands. While dangerous command patterns are File operations have path traversal protection, but: +- ✅ Enable `restrictToWorkspace` or the bwrap sandbox to confine file access - ✅ Run nanobot with a dedicated user account - ✅ Use filesystem permissions to protect sensitive directories - ✅ Regularly audit file operations in logs @@ -232,7 +247,7 @@ If you suspect a security breach: 1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed) 2. **Plain Text Config** - API keys stored in plain text (use keyring for production) 3. **No Session Management** - No automatic session expiry -4. **Limited Command Filtering** - Only blocks obvious dangerous patterns +4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux) 5. **No Audit Trail** - Limited security event logging (enhance as needed) ## Security Checklist @@ -243,6 +258,7 @@ Before deploying nanobot: - [ ] Config file permissions set to 0600 - [ ] `allowFrom` lists configured for all channels - [ ] Running as non-root user +- [ ] Exec sandbox enabled (`"tools.exec.sandbox": "bwrap"`) on Linux deployments - [ ] File system permissions properly restricted - [ ] Dependencies updated to latest secure versions - [ ] Logs monitored for security events @@ -252,7 +268,7 @@ Before deploying nanobot: ## Updates -**Last Updated**: 2026-02-03 +**Last Updated**: 2026-04-05 For the latest security updates and announcements, check: - GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories From 861072519a616cb34c7c6ad0c9a264e828377c5a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:59:49 +0000 Subject: [PATCH 24/85] chore: remove codespell CI workflow and config, keep typo fixes only Made-with: Cursor --- .github/workflows/codespell.yml | 23 ----------------------- pyproject.toml | 7 ------- 2 files changed, 30 deletions(-) delete mode 100644 .github/workflows/codespell.yml diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index dd0eb8e57..000000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Codespell configuration is within pyproject.toml ---- -name: Codespell - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Codespell - uses: codespell-project/actions-codespell@v2 diff --git a/pyproject.toml b/pyproject.toml index 018827a85..ae87c7beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,13 +130,6 @@ ignore = ["E501"] asyncio_mode = "auto" testpaths = ["tests"] -[tool.codespell] -# Ref: https://github.com/codespell-project/codespell#using-a-config-file -skip = '.git*' -check-hidden = true -# ignore-regex = '' -# ignore-words-list = '' - [tool.coverage.run] source = ["nanobot"] omit = ["tests/*", "**/tests/*"] From 3c28d1e6517ff873fa4e8b08f307de69d9972c27 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 20:06:38 +0000 Subject: [PATCH 25/85] docs: rename Assistant to Agent across README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 543bcb0c0..a6a6525af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
nanobot -

nanobot: Ultra-Lightweight Personal AI Assistant

+

nanobot: Ultra-Lightweight Personal AI Agent

PyPI Downloads @@ -14,7 +14,7 @@ 🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). -⚡️ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw. +⚡️ Delivers core agent functionality with **99% fewer lines of code**. 📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime. @@ -91,7 +91,7 @@ ## Key Features of nanobot: -🪶 **Ultra-Lightweight**: A super lightweight implementation of OpenClaw — 99% smaller, significantly faster. +🪶 **Ultra-Lightweight**: A lightweight implementation built for stable, long-running AI agents — minimal footprint, significantly faster. 🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research. From 84b1c6a0d7df61940f01cc391bf970f29e964aa3 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 20:07:11 +0000 Subject: [PATCH 26/85] docs: update nanobot features --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a6a6525af..0ae6820e5 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ ## Key Features of nanobot: -🪶 **Ultra-Lightweight**: A lightweight implementation built for stable, long-running AI agents — minimal footprint, significantly faster. +🪶 **Ultra-Lightweight**: A lightweight implementation built for stable, long-running AI agents. 🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research. From be6063a14228aeb0fda24b5eb36724f2ff6b3473 Mon Sep 17 00:00:00 2001 From: Ben Lenarts Date: Mon, 6 Apr 2026 00:21:07 +0200 Subject: [PATCH 27/85] security: prevent exec tool from leaking process env vars to LLM The exec tool previously passed the full parent process environment to child processes, which meant LLM-generated commands could access secrets stored in env vars (e.g. API keys from EnvironmentFile=). Switch from subprocess_shell with inherited env to bash login shell with a minimal environment (HOME, LANG, TERM only). The login shell sources the user's profile for PATH setup, making the pathAppend config option a fallback rather than the primary PATH mechanism. --- nanobot/agent/tools/shell.py | 30 +++++++++++++++++++++++++----- tests/tools/test_exec_env.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 tests/tools/test_exec_env.py diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index ec2f1a775..2e0b606ab 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -3,6 +3,7 @@ import asyncio import os import re +import shutil import sys from pathlib import Path from typing import Any @@ -93,13 +94,13 @@ class ExecTool(Tool): effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT) - env = os.environ.copy() - if self.path_append: - env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append + env = self._build_env() + + bash = shutil.which("bash") or "/bin/bash" try: - process = await asyncio.create_subprocess_shell( - command, + process = await asyncio.create_subprocess_exec( + bash, "-l", "-c", command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -154,6 +155,25 @@ class ExecTool(Tool): except Exception as e: return f"Error executing command: {str(e)}" + def _build_env(self) -> dict[str, str]: + """Build a minimal environment for subprocess execution. + + Uses HOME so that ``bash -l`` sources the user's profile (which sets + PATH and other essentials). Only PATH is extended with *path_append*; + the parent process's environment is **not** inherited, preventing + secrets in env vars from leaking to LLM-generated commands. + """ + home = os.environ.get("HOME", "/tmp") + env: dict[str, str] = { + "HOME": home, + "LANG": os.environ.get("LANG", "C.UTF-8"), + "TERM": os.environ.get("TERM", "dumb"), + } + if self.path_append: + # Seed PATH so the login shell can append to it. + env["PATH"] = self.path_append + return env + def _guard_command(self, command: str, cwd: str) -> str | None: """Best-effort safety guard for potentially destructive commands.""" cmd = command.strip() diff --git a/tests/tools/test_exec_env.py b/tests/tools/test_exec_env.py new file mode 100644 index 000000000..30358e688 --- /dev/null +++ b/tests/tools/test_exec_env.py @@ -0,0 +1,30 @@ +"""Tests for exec tool environment isolation.""" + +import pytest + +from nanobot.agent.tools.shell import ExecTool + + +@pytest.mark.asyncio +async def test_exec_does_not_leak_parent_env(monkeypatch): + """Env vars from the parent process must not be visible to commands.""" + monkeypatch.setenv("NANOBOT_SECRET_TOKEN", "super-secret-value") + tool = ExecTool() + result = await tool.execute(command="printenv NANOBOT_SECRET_TOKEN") + assert "super-secret-value" not in result + + +@pytest.mark.asyncio +async def test_exec_has_working_path(): + """Basic commands should be available via the login shell's PATH.""" + tool = ExecTool() + result = await tool.execute(command="echo hello") + assert "hello" in result + + +@pytest.mark.asyncio +async def test_exec_path_append(): + """The pathAppend config should be available in the command's PATH.""" + tool = ExecTool(path_append="/opt/custom/bin") + result = await tool.execute(command="echo $PATH") + assert "/opt/custom/bin" in result From 28e0a76b8050f041f97a93404f3139b7157ea312 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:19:06 +0000 Subject: [PATCH 28/85] fix: path_append must not clobber login shell PATH Seeding PATH in the env before bash -l caused /etc/profile to skip its default PATH setup, breaking standard commands. Move path_append to an inline export so the login shell establishes a proper base PATH first. Add regression test: ls still works when path_append is set. Made-with: Cursor --- nanobot/agent/tools/shell.py | 9 ++++----- tests/tools/test_exec_env.py | 8 ++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 2e0b606ab..e6e9ac0f5 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -96,6 +96,9 @@ class ExecTool(Tool): env = self._build_env() + if self.path_append: + command = f'export PATH="$PATH:{self.path_append}"; {command}' + bash = shutil.which("bash") or "/bin/bash" try: @@ -164,15 +167,11 @@ class ExecTool(Tool): secrets in env vars from leaking to LLM-generated commands. """ home = os.environ.get("HOME", "/tmp") - env: dict[str, str] = { + return { "HOME": home, "LANG": os.environ.get("LANG", "C.UTF-8"), "TERM": os.environ.get("TERM", "dumb"), } - if self.path_append: - # Seed PATH so the login shell can append to it. - env["PATH"] = self.path_append - return env def _guard_command(self, command: str, cwd: str) -> str | None: """Best-effort safety guard for potentially destructive commands.""" diff --git a/tests/tools/test_exec_env.py b/tests/tools/test_exec_env.py index 30358e688..e5c0f48bb 100644 --- a/tests/tools/test_exec_env.py +++ b/tests/tools/test_exec_env.py @@ -28,3 +28,11 @@ async def test_exec_path_append(): tool = ExecTool(path_append="/opt/custom/bin") result = await tool.execute(command="echo $PATH") assert "/opt/custom/bin" in result + + +@pytest.mark.asyncio +async def test_exec_path_append_preserves_system_path(): + """pathAppend must not clobber standard system paths.""" + tool = ExecTool(path_append="/opt/custom/bin") + result = await tool.execute(command="ls /") + assert "Exit code: 0" in result From b2e751f21b5a65344f040fc1b0f527835da174ea Mon Sep 17 00:00:00 2001 From: qixinbo Date: Mon, 6 Apr 2026 10:15:44 +0800 Subject: [PATCH 29/85] docs: another two places for renaming assitant to agent --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ae6820e5..e5853bf08 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

-🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). +🐈 **nanobot** is an **ultra-lightweight** personal AI agent inspired by [OpenClaw](https://github.com/openclaw/openclaw). ⚡️ Delivers core agent functionality with **99% fewer lines of code**. @@ -252,7 +252,7 @@ Configure these **two parts** in your config (other options have defaults). nanobot agent ``` -That's it! You have a working AI assistant in 2 minutes. +That's it! You have a working AI agent in 2 minutes. ## 💬 Chat Apps From bc0ff7f2143e6f07131133abbe15eb1928446fe4 Mon Sep 17 00:00:00 2001 From: whs Date: Mon, 6 Apr 2026 07:00:02 +0800 Subject: [PATCH 30/85] feat(status): add web search provider usage to /status command --- nanobot/agent/tools/search_usage.py | 183 +++++++++++++++++ nanobot/command/builtin.py | 16 ++ nanobot/utils/helpers.py | 16 +- tests/tools/__init__.py | 0 tests/tools/test_search_usage.py | 303 ++++++++++++++++++++++++++++ 5 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 nanobot/agent/tools/search_usage.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/test_search_usage.py diff --git a/nanobot/agent/tools/search_usage.py b/nanobot/agent/tools/search_usage.py new file mode 100644 index 000000000..70fecb8c6 --- /dev/null +++ b/nanobot/agent/tools/search_usage.py @@ -0,0 +1,183 @@ +"""Web search provider usage fetchers for /status command.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + + +@dataclass +class SearchUsageInfo: + """Structured usage info returned by a provider fetcher.""" + + provider: str + supported: bool = False # True if the provider has a usage API + error: str | None = None # Set when the API call failed + + # Usage counters (None = not available for this provider) + used: int | None = None + limit: int | None = None + remaining: int | None = None + reset_date: str | None = None # ISO date string, e.g. "2026-05-01" + + # Tavily-specific breakdown + search_used: int | None = None + extract_used: int | None = None + crawl_used: int | None = None + + def format(self) -> str: + """Return a human-readable multi-line string for /status output.""" + lines = [f"🔍 Web Search: {self.provider}"] + + if not self.supported: + lines.append(" Usage tracking: not available for this provider") + return "\n".join(lines) + + if self.error: + lines.append(f" Usage: unavailable ({self.error})") + return "\n".join(lines) + + if self.used is not None and self.limit is not None: + lines.append(f" Usage: {self.used} / {self.limit} requests") + elif self.used is not None: + lines.append(f" Usage: {self.used} requests") + + # Tavily breakdown + breakdown_parts = [] + if self.search_used is not None: + breakdown_parts.append(f"Search: {self.search_used}") + if self.extract_used is not None: + breakdown_parts.append(f"Extract: {self.extract_used}") + if self.crawl_used is not None: + breakdown_parts.append(f"Crawl: {self.crawl_used}") + if breakdown_parts: + lines.append(f" Breakdown: {' | '.join(breakdown_parts)}") + + if self.remaining is not None: + lines.append(f" Remaining: {self.remaining} requests") + + if self.reset_date: + lines.append(f" Resets: {self.reset_date}") + + return "\n".join(lines) + + +async def fetch_search_usage( + provider: str, + api_key: str | None = None, +) -> SearchUsageInfo: + """ + Fetch usage info for the configured web search provider. + + Args: + provider: Provider name (e.g. "tavily", "brave", "duckduckgo"). + api_key: API key for the provider (falls back to env vars). + + Returns: + SearchUsageInfo with populated fields where available. + """ + p = (provider or "duckduckgo").strip().lower() + + if p == "tavily": + return await _fetch_tavily_usage(api_key) + elif p == "brave": + return await _fetch_brave_usage(api_key) + else: + # duckduckgo, searxng, jina, unknown — no usage API + return SearchUsageInfo(provider=p, supported=False) + + +# --------------------------------------------------------------------------- +# Tavily +# --------------------------------------------------------------------------- + +async def _fetch_tavily_usage(api_key: str | None) -> SearchUsageInfo: + """Fetch usage from GET https://api.tavily.com/usage.""" + import httpx + + key = api_key or os.environ.get("TAVILY_API_KEY", "") + if not key: + return SearchUsageInfo( + provider="tavily", + supported=True, + error="TAVILY_API_KEY not configured", + ) + + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get( + "https://api.tavily.com/usage", + headers={"Authorization": f"Bearer {key}"}, + ) + r.raise_for_status() + data: dict[str, Any] = r.json() + return _parse_tavily_usage(data) + except httpx.HTTPStatusError as e: + return SearchUsageInfo( + provider="tavily", + supported=True, + error=f"HTTP {e.response.status_code}", + ) + except Exception as e: + return SearchUsageInfo( + provider="tavily", + supported=True, + error=str(e)[:80], + ) + + +def _parse_tavily_usage(data: dict[str, Any]) -> SearchUsageInfo: + """ + Parse Tavily /usage response. + + Expected shape (may vary by plan): + { + "used": 142, + "limit": 1000, + "remaining": 858, + "reset_date": "2026-05-01", + "breakdown": { + "search": 120, + "extract": 15, + "crawl": 7 + } + } + """ + used = data.get("used") + limit = data.get("limit") + remaining = data.get("remaining") + reset_date = data.get("reset_date") or data.get("resetDate") + + # Compute remaining if not provided + if remaining is None and used is not None and limit is not None: + remaining = max(0, limit - used) + + breakdown = data.get("breakdown") or {} + search_used = breakdown.get("search") + extract_used = breakdown.get("extract") + crawl_used = breakdown.get("crawl") + + return SearchUsageInfo( + provider="tavily", + supported=True, + used=used, + limit=limit, + remaining=remaining, + reset_date=str(reset_date) if reset_date else None, + search_used=search_used, + extract_used=extract_used, + crawl_used=crawl_used, + ) + + +# --------------------------------------------------------------------------- +# Brave +# --------------------------------------------------------------------------- + +async def _fetch_brave_usage(api_key: str | None) -> SearchUsageInfo: + """ + Brave Search does not have a public usage/quota endpoint. + Rate-limit headers are returned per-request, not queryable standalone. + """ + return SearchUsageInfo(provider="brave", supported=False) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 514ac1438..81623ebd5 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -60,6 +60,21 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: pass if ctx_est <= 0: ctx_est = loop._last_usage.get("prompt_tokens", 0) + + # Fetch web search provider usage (best-effort, never blocks the response) + search_usage_text: str | None = None + try: + from nanobot.agent.tools.search_usage import fetch_search_usage + web_cfg = getattr(getattr(loop, "config", None), "tools", None) + web_cfg = getattr(web_cfg, "web", None) if web_cfg else None + search_cfg = getattr(web_cfg, "search", None) if web_cfg else None + if search_cfg is not None: + provider = getattr(search_cfg, "provider", "duckduckgo") + api_key = getattr(search_cfg, "api_key", "") or None + usage = await fetch_search_usage(provider=provider, api_key=api_key) + search_usage_text = usage.format() + except Exception: + pass # Never let usage fetch break /status return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, @@ -69,6 +84,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: context_window_tokens=loop.context_window_tokens, session_msg_count=len(session.get_history(max_messages=0)), context_tokens_estimate=ctx_est, + search_usage_text=search_usage_text, ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 93293c9e0..7267bac2a 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -396,8 +396,15 @@ def build_status_content( context_window_tokens: int, session_msg_count: int, context_tokens_estimate: int, + search_usage_text: str | None = None, ) -> str: - """Build a human-readable runtime status snapshot.""" + """Build a human-readable runtime status snapshot. + + Args: + search_usage_text: Optional pre-formatted web search usage string + (produced by SearchUsageInfo.format()). When provided + it is appended as an extra section. + """ uptime_s = int(time.time() - start_time) uptime = ( f"{uptime_s // 3600}h {(uptime_s % 3600) // 60}m" @@ -414,14 +421,17 @@ def build_status_content( token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out" if cached and last_in: token_line += f" ({cached * 100 // last_in}% cached)" - return "\n".join([ + lines = [ f"\U0001f408 nanobot v{version}", f"\U0001f9e0 Model: {model}", token_line, f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)", f"\U0001f4ac Session: {session_msg_count} messages", f"\u23f1 Uptime: {uptime}", - ]) + ] + if search_usage_text: + lines.append(search_usage_text) + return "\n".join(lines) def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tools/test_search_usage.py b/tests/tools/test_search_usage.py new file mode 100644 index 000000000..faec41dfa --- /dev/null +++ b/tests/tools/test_search_usage.py @@ -0,0 +1,303 @@ +"""Tests for web search provider usage fetching and /status integration.""" + +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from nanobot.agent.tools.search_usage import ( + SearchUsageInfo, + _parse_tavily_usage, + fetch_search_usage, +) +from nanobot.utils.helpers import build_status_content + + +# --------------------------------------------------------------------------- +# SearchUsageInfo.format() tests +# --------------------------------------------------------------------------- + +class TestSearchUsageInfoFormat: + def test_unsupported_provider_shows_no_tracking(self): + info = SearchUsageInfo(provider="duckduckgo", supported=False) + text = info.format() + assert "duckduckgo" in text + assert "not available" in text + + def test_supported_with_error(self): + info = SearchUsageInfo(provider="tavily", supported=True, error="HTTP 401") + text = info.format() + assert "tavily" in text + assert "HTTP 401" in text + assert "unavailable" in text + + def test_full_tavily_usage(self): + info = SearchUsageInfo( + provider="tavily", + supported=True, + used=142, + limit=1000, + remaining=858, + reset_date="2026-05-01", + search_used=120, + extract_used=15, + crawl_used=7, + ) + text = info.format() + assert "tavily" in text + assert "142 / 1000" in text + assert "858" in text + assert "2026-05-01" in text + assert "Search: 120" in text + assert "Extract: 15" in text + assert "Crawl: 7" in text + + def test_usage_without_limit(self): + info = SearchUsageInfo(provider="tavily", supported=True, used=50) + text = info.format() + assert "50 requests" in text + assert "/" not in text.split("Usage:")[1].split("\n")[0] + + def test_no_breakdown_when_none(self): + info = SearchUsageInfo( + provider="tavily", supported=True, used=10, limit=100, remaining=90 + ) + text = info.format() + assert "Breakdown" not in text + + def test_brave_unsupported(self): + info = SearchUsageInfo(provider="brave", supported=False) + text = info.format() + assert "brave" in text + assert "not available" in text + + +# --------------------------------------------------------------------------- +# _parse_tavily_usage tests +# --------------------------------------------------------------------------- + +class TestParseTavilyUsage: + def test_full_response(self): + data = { + "used": 142, + "limit": 1000, + "remaining": 858, + "reset_date": "2026-05-01", + "breakdown": {"search": 120, "extract": 15, "crawl": 7}, + } + info = _parse_tavily_usage(data) + assert info.provider == "tavily" + assert info.supported is True + assert info.used == 142 + assert info.limit == 1000 + assert info.remaining == 858 + assert info.reset_date == "2026-05-01" + assert info.search_used == 120 + assert info.extract_used == 15 + assert info.crawl_used == 7 + + def test_remaining_computed_when_missing(self): + data = {"used": 300, "limit": 1000} + info = _parse_tavily_usage(data) + assert info.remaining == 700 + + def test_remaining_not_negative(self): + data = {"used": 1100, "limit": 1000} + info = _parse_tavily_usage(data) + assert info.remaining == 0 + + def test_camel_case_reset_date(self): + data = {"used": 10, "limit": 100, "resetDate": "2026-06-01"} + info = _parse_tavily_usage(data) + assert info.reset_date == "2026-06-01" + + def test_empty_response(self): + info = _parse_tavily_usage({}) + assert info.provider == "tavily" + assert info.supported is True + assert info.used is None + assert info.limit is None + + def test_no_breakdown_key(self): + data = {"used": 5, "limit": 50} + info = _parse_tavily_usage(data) + assert info.search_used is None + assert info.extract_used is None + assert info.crawl_used is None + + +# --------------------------------------------------------------------------- +# fetch_search_usage routing tests +# --------------------------------------------------------------------------- + +class TestFetchSearchUsageRouting: + @pytest.mark.asyncio + async def test_duckduckgo_returns_unsupported(self): + info = await fetch_search_usage("duckduckgo") + assert info.provider == "duckduckgo" + assert info.supported is False + + @pytest.mark.asyncio + async def test_searxng_returns_unsupported(self): + info = await fetch_search_usage("searxng") + assert info.supported is False + + @pytest.mark.asyncio + async def test_jina_returns_unsupported(self): + info = await fetch_search_usage("jina") + assert info.supported is False + + @pytest.mark.asyncio + async def test_brave_returns_unsupported(self): + info = await fetch_search_usage("brave") + assert info.provider == "brave" + assert info.supported is False + + @pytest.mark.asyncio + async def test_unknown_provider_returns_unsupported(self): + info = await fetch_search_usage("some_unknown_provider") + assert info.supported is False + + @pytest.mark.asyncio + async def test_tavily_no_api_key_returns_error(self): + with patch.dict("os.environ", {}, clear=True): + # Ensure TAVILY_API_KEY is not set + import os + os.environ.pop("TAVILY_API_KEY", None) + info = await fetch_search_usage("tavily", api_key=None) + assert info.provider == "tavily" + assert info.supported is True + assert info.error is not None + assert "not configured" in info.error + + @pytest.mark.asyncio + async def test_tavily_success(self): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "used": 142, + "limit": 1000, + "remaining": 858, + "reset_date": "2026-05-01", + "breakdown": {"search": 120, "extract": 15, "crawl": 7}, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + info = await fetch_search_usage("tavily", api_key="test-key") + + assert info.provider == "tavily" + assert info.supported is True + assert info.error is None + assert info.used == 142 + assert info.limit == 1000 + assert info.remaining == 858 + assert info.reset_date == "2026-05-01" + assert info.search_used == 120 + + @pytest.mark.asyncio + async def test_tavily_http_error(self): + import httpx + + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401", request=MagicMock(), response=mock_response + ) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + info = await fetch_search_usage("tavily", api_key="bad-key") + + assert info.supported is True + assert info.error == "HTTP 401" + + @pytest.mark.asyncio + async def test_tavily_network_error(self): + import httpx + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("timeout")) + + with patch("httpx.AsyncClient", return_value=mock_client): + info = await fetch_search_usage("tavily", api_key="test-key") + + assert info.supported is True + assert info.error is not None + + @pytest.mark.asyncio + async def test_provider_name_case_insensitive(self): + info = await fetch_search_usage("Tavily", api_key=None) + assert info.provider == "tavily" + assert info.supported is True + + +# --------------------------------------------------------------------------- +# build_status_content integration tests +# --------------------------------------------------------------------------- + +class TestBuildStatusContentWithSearchUsage: + _BASE_KWARGS = dict( + version="0.1.0", + model="claude-opus-4-5", + start_time=1_000_000.0, + last_usage={"prompt_tokens": 1000, "completion_tokens": 200}, + context_window_tokens=65536, + session_msg_count=5, + context_tokens_estimate=3000, + ) + + def test_no_search_usage_unchanged(self): + """Omitting search_usage_text keeps existing behaviour.""" + content = build_status_content(**self._BASE_KWARGS) + assert "🔍" not in content + assert "Web Search" not in content + + def test_search_usage_none_unchanged(self): + content = build_status_content(**self._BASE_KWARGS, search_usage_text=None) + assert "🔍" not in content + + def test_search_usage_appended(self): + usage_text = "🔍 Web Search: tavily\n Usage: 142 / 1000 requests" + content = build_status_content(**self._BASE_KWARGS, search_usage_text=usage_text) + assert "🔍 Web Search: tavily" in content + assert "142 / 1000" in content + + def test_existing_fields_still_present(self): + usage_text = "🔍 Web Search: duckduckgo\n Usage tracking: not available" + content = build_status_content(**self._BASE_KWARGS, search_usage_text=usage_text) + # Original fields must still be present + assert "nanobot v0.1.0" in content + assert "claude-opus-4-5" in content + assert "1000 in / 200 out" in content + # New field appended + assert "duckduckgo" in content + + def test_full_tavily_in_status(self): + info = SearchUsageInfo( + provider="tavily", + supported=True, + used=142, + limit=1000, + remaining=858, + reset_date="2026-05-01", + search_used=120, + extract_used=15, + crawl_used=7, + ) + content = build_status_content(**self._BASE_KWARGS, search_usage_text=info.format()) + assert "142 / 1000" in content + assert "858" in content + assert "2026-05-01" in content + assert "Search: 120" in content From 7ffd93f48dae083af06c2ddec2ba87c6c57d8e5b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:34:44 +0000 Subject: [PATCH 31/85] refactor: move search_usage to utils/searchusage, remove brave stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename agent/tools/search_usage.py → utils/searchusage.py (not an LLM tool, matches utils/ naming convention) - Remove redundant _fetch_brave_usage — handled by else branch - Move test to tests/utils/test_searchusage.py Made-with: Cursor --- nanobot/command/builtin.py | 2 +- .../tools/search_usage.py => utils/searchusage.py} | 14 +------------- .../test_searchusage.py} | 2 +- 3 files changed, 3 insertions(+), 15 deletions(-) rename nanobot/{agent/tools/search_usage.py => utils/searchusage.py} (89%) rename tests/{tools/test_search_usage.py => utils/test_searchusage.py} (99%) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 81623ebd5..8ead6a131 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -64,7 +64,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: # Fetch web search provider usage (best-effort, never blocks the response) search_usage_text: str | None = None try: - from nanobot.agent.tools.search_usage import fetch_search_usage + from nanobot.utils.searchusage import fetch_search_usage web_cfg = getattr(getattr(loop, "config", None), "tools", None) web_cfg = getattr(web_cfg, "web", None) if web_cfg else None search_cfg = getattr(web_cfg, "search", None) if web_cfg else None diff --git a/nanobot/agent/tools/search_usage.py b/nanobot/utils/searchusage.py similarity index 89% rename from nanobot/agent/tools/search_usage.py rename to nanobot/utils/searchusage.py index 70fecb8c6..3e0c86101 100644 --- a/nanobot/agent/tools/search_usage.py +++ b/nanobot/utils/searchusage.py @@ -81,10 +81,8 @@ async def fetch_search_usage( if p == "tavily": return await _fetch_tavily_usage(api_key) - elif p == "brave": - return await _fetch_brave_usage(api_key) else: - # duckduckgo, searxng, jina, unknown — no usage API + # brave, duckduckgo, searxng, jina, unknown — no usage API return SearchUsageInfo(provider=p, supported=False) @@ -171,13 +169,3 @@ def _parse_tavily_usage(data: dict[str, Any]) -> SearchUsageInfo: ) -# --------------------------------------------------------------------------- -# Brave -# --------------------------------------------------------------------------- - -async def _fetch_brave_usage(api_key: str | None) -> SearchUsageInfo: - """ - Brave Search does not have a public usage/quota endpoint. - Rate-limit headers are returned per-request, not queryable standalone. - """ - return SearchUsageInfo(provider="brave", supported=False) diff --git a/tests/tools/test_search_usage.py b/tests/utils/test_searchusage.py similarity index 99% rename from tests/tools/test_search_usage.py rename to tests/utils/test_searchusage.py index faec41dfa..dd8c62571 100644 --- a/tests/tools/test_search_usage.py +++ b/tests/utils/test_searchusage.py @@ -5,7 +5,7 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch -from nanobot.agent.tools.search_usage import ( +from nanobot.utils.searchusage import ( SearchUsageInfo, _parse_tavily_usage, fetch_search_usage, From 202938ae7355aa5817923d7fd80a4855e3c94118 Mon Sep 17 00:00:00 2001 From: Ben Lenarts Date: Sun, 5 Apr 2026 23:55:50 +0200 Subject: [PATCH 32/85] feat: support ${VAR} env var interpolation in config secrets Allow config.json to reference environment variables via ${VAR_NAME} syntax. Variables are resolved at runtime by resolve_config_env_vars(), keeping the raw templates in the Pydantic model so save_config() preserves them. This lets secrets live in a separate env file (e.g. loaded by systemd EnvironmentFile=) instead of plain text in config.json. --- README.md | 35 +++++++++++ nanobot/cli/commands.py | 8 ++- nanobot/config/loader.py | 34 +++++++++++ nanobot/nanobot.py | 4 +- tests/cli/test_commands.py | 2 + tests/config/test_env_interpolation.py | 82 ++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/config/test_env_interpolation.py diff --git a/README.md b/README.md index e5853bf08..e8629f6b8 100644 --- a/README.md +++ b/README.md @@ -861,6 +861,41 @@ Config file: `~/.nanobot/config.json` > run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. > nanobot will merge in missing default fields and keep your current settings. +### Environment Variables for Secrets + +Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}` references that are resolved from environment variables at startup: + +```json +{ + "channels": { + "telegram": { "token": "${TELEGRAM_TOKEN}" }, + "email": { + "imapPassword": "${IMAP_PASSWORD}", + "smtpPassword": "${SMTP_PASSWORD}" + } + }, + "providers": { + "groq": { "apiKey": "${GROQ_API_KEY}" } + } +} +``` + +For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read: + +```ini +# /etc/systemd/system/nanobot.service (excerpt) +[Service] +EnvironmentFile=/home/youruser/nanobot_secrets.env +User=nanobot +ExecStart=... +``` + +```bash +# /home/youruser/nanobot_secrets.env (mode 600, owned by youruser) +TELEGRAM_TOKEN=your-token-here +IMAP_PASSWORD=your-password-here +``` + ### Providers > [!TIP] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index dfb13ba97..ca26cbf37 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -453,7 +453,7 @@ def _make_provider(config: Config): def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: """Load config and optionally override the active workspace.""" - from nanobot.config.loader import load_config, set_config_path + from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path config_path = None if config: @@ -464,7 +464,11 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None set_config_path(config_path) console.print(f"[dim]Using config: {config_path}[/dim]") - loaded = load_config(config_path) + try: + loaded = resolve_config_env_vars(load_config(config_path)) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) _warn_deprecated_config_keys(config_path) if workspace: loaded.agents.defaults.workspace = workspace diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index f5b2f33b8..618334c1c 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -1,6 +1,8 @@ """Configuration loading utilities.""" import json +import os +import re from pathlib import Path import pydantic @@ -76,6 +78,38 @@ def save_config(config: Config, config_path: Path | None = None) -> None: json.dump(data, f, indent=2, ensure_ascii=False) +def resolve_config_env_vars(config: Config) -> Config: + """Return a copy of *config* with ``${VAR}`` env-var references resolved. + + Only string values are affected; other types pass through unchanged. + Raises :class:`ValueError` if a referenced variable is not set. + """ + data = config.model_dump(mode="json", by_alias=True) + data = _resolve_env_vars(data) + return Config.model_validate(data) + + +def _resolve_env_vars(obj: object) -> object: + """Recursively resolve ``${VAR}`` patterns in string values.""" + if isinstance(obj, str): + return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _env_replace, obj) + if isinstance(obj, dict): + return {k: _resolve_env_vars(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_resolve_env_vars(v) for v in obj] + return obj + + +def _env_replace(match: re.Match[str]) -> str: + name = match.group(1) + value = os.environ.get(name) + if value is None: + raise ValueError( + f"Environment variable '{name}' referenced in config is not set" + ) + return value + + def _migrate_config(data: dict) -> dict: """Migrate old config formats to current.""" # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 4860fa312..85e9e1ddb 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -47,7 +47,7 @@ class Nanobot: ``~/.nanobot/config.json``. workspace: Override the workspace directory from config. """ - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.schema import Config resolved: Path | None = None @@ -56,7 +56,7 @@ class Nanobot: if not resolved.exists(): raise FileNotFoundError(f"Config not found: {resolved}") - config: Config = load_config(resolved) + config: Config = resolve_config_env_vars(load_config(resolved)) if workspace is not None: config.agents.defaults.workspace = str( Path(workspace).expanduser().resolve() diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 0f6ff8177..4a1a00632 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -425,6 +425,7 @@ def mock_agent_runtime(tmp_path): config.agents.defaults.workspace = str(tmp_path / "default-workspace") with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ + patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands._make_provider", return_value=object()), \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ @@ -739,6 +740,7 @@ def _patch_cli_command_runtime( set_config_path or (lambda _path: None), ) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c) monkeypatch.setattr( "nanobot.cli.commands.sync_workspace_templates", sync_templates or (lambda _path: None), diff --git a/tests/config/test_env_interpolation.py b/tests/config/test_env_interpolation.py new file mode 100644 index 000000000..aefcc3e40 --- /dev/null +++ b/tests/config/test_env_interpolation.py @@ -0,0 +1,82 @@ +import json + +import pytest + +from nanobot.config.loader import ( + _resolve_env_vars, + load_config, + resolve_config_env_vars, + save_config, +) + + +class TestResolveEnvVars: + def test_replaces_string_value(self, monkeypatch): + monkeypatch.setenv("MY_SECRET", "hunter2") + assert _resolve_env_vars("${MY_SECRET}") == "hunter2" + + def test_partial_replacement(self, monkeypatch): + monkeypatch.setenv("HOST", "example.com") + assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api" + + def test_multiple_vars_in_one_string(self, monkeypatch): + monkeypatch.setenv("USER", "alice") + monkeypatch.setenv("PASS", "secret") + assert _resolve_env_vars("${USER}:${PASS}") == "alice:secret" + + def test_nested_dicts(self, monkeypatch): + monkeypatch.setenv("TOKEN", "abc123") + data = {"channels": {"telegram": {"token": "${TOKEN}"}}} + result = _resolve_env_vars(data) + assert result["channels"]["telegram"]["token"] == "abc123" + + def test_lists(self, monkeypatch): + monkeypatch.setenv("VAL", "x") + assert _resolve_env_vars(["${VAL}", "plain"]) == ["x", "plain"] + + def test_ignores_non_strings(self): + assert _resolve_env_vars(42) == 42 + assert _resolve_env_vars(True) is True + assert _resolve_env_vars(None) is None + assert _resolve_env_vars(3.14) == 3.14 + + def test_plain_strings_unchanged(self): + assert _resolve_env_vars("no vars here") == "no vars here" + + def test_missing_var_raises(self): + with pytest.raises(ValueError, match="DOES_NOT_EXIST"): + _resolve_env_vars("${DOES_NOT_EXIST}") + + +class TestResolveConfig: + def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("TEST_API_KEY", "resolved-key") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + {"providers": {"groq": {"apiKey": "${TEST_API_KEY}"}}} + ), + encoding="utf-8", + ) + + raw = load_config(config_path) + assert raw.providers.groq.api_key == "${TEST_API_KEY}" + + resolved = resolve_config_env_vars(raw) + assert resolved.providers.groq.api_key == "resolved-key" + + def test_save_preserves_templates(self, tmp_path, monkeypatch): + monkeypatch.setenv("MY_TOKEN", "real-token") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + {"channels": {"telegram": {"token": "${MY_TOKEN}"}}} + ), + encoding="utf-8", + ) + + raw = load_config(config_path) + save_config(raw, config_path) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}" From 0e617c32cd580a030910a87fb95a4700dc3be28b Mon Sep 17 00:00:00 2001 From: Lingao Meng Date: Fri, 3 Apr 2026 10:20:45 +0800 Subject: [PATCH 33/85] fix(shell): kill subprocess on CancelledError to prevent orphan processes When an agent task is cancelled (e.g. via /stop), the ExecTool was only handling TimeoutError but not CancelledError. This left the child process running as an orphan. Now CancelledError also triggers process.kill() and waitpid cleanup before re-raising. --- nanobot/agent/tools/shell.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index e6e9ac0f5..085d74d1c 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -128,6 +128,19 @@ class ExecTool(Tool): except (ProcessLookupError, ChildProcessError) as e: logger.debug("Process already reaped or not found: {}", e) return f"Error: Command timed out after {effective_timeout} seconds" + except asyncio.CancelledError: + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass + finally: + if sys.platform != "win32": + try: + os.waitpid(process.pid, os.WNOHANG) + except (ProcessLookupError, ChildProcessError) as e: + logger.debug("Process already reaped or not found: {}", e) + raise output_parts = [] From 424b9fc26215d91acef53e4e4bb74a3a7cfdc7bb Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:45:46 +0000 Subject: [PATCH 34/85] refactor: extract _kill_process helper to DRY timeout/cancel cleanup Made-with: Cursor --- nanobot/agent/tools/shell.py | 39 ++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 085d74d1c..e5c04eb72 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -116,30 +116,10 @@ class ExecTool(Tool): timeout=effective_timeout, ) except asyncio.TimeoutError: - process.kill() - try: - await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: - pass - finally: - if sys.platform != "win32": - try: - os.waitpid(process.pid, os.WNOHANG) - except (ProcessLookupError, ChildProcessError) as e: - logger.debug("Process already reaped or not found: {}", e) + await self._kill_process(process) return f"Error: Command timed out after {effective_timeout} seconds" except asyncio.CancelledError: - process.kill() - try: - await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: - pass - finally: - if sys.platform != "win32": - try: - os.waitpid(process.pid, os.WNOHANG) - except (ProcessLookupError, ChildProcessError) as e: - logger.debug("Process already reaped or not found: {}", e) + await self._kill_process(process) raise output_parts = [] @@ -171,6 +151,21 @@ class ExecTool(Tool): except Exception as e: return f"Error executing command: {str(e)}" + @staticmethod + async def _kill_process(process: asyncio.subprocess.Process) -> None: + """Kill a subprocess and reap it to prevent zombies.""" + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass + finally: + if sys.platform != "win32": + try: + os.waitpid(process.pid, os.WNOHANG) + except (ProcessLookupError, ChildProcessError) as e: + logger.debug("Process already reaped or not found: {}", e) + def _build_env(self) -> dict[str, str]: """Build a minimal environment for subprocess execution. From 1b368a33dcb66c16cbe5e8b9cd5f49e5a01d9582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=89=E6=B3=89?= Date: Wed, 1 Apr 2026 23:23:23 +0800 Subject: [PATCH 35/85] fix(feishu): match bot's own open_id in _is_bot_mentioned to prevent cross-bot false positives Previously, _is_bot_mentioned used a heuristic (no user_id + open_id prefix "ou_") which caused other bots in the same group to falsely think they were mentioned. Now fetches the bot's own open_id via GET /open-apis/bot/v3/info at startup and does an exact match. --- nanobot/channels/feishu.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1128c0e16..323b51fe5 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -298,6 +298,7 @@ class FeishuChannel(BaseChannel): self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache self._loop: asyncio.AbstractEventLoop | None = None self._stream_bufs: dict[str, _FeishuStreamBuf] = {} + self._bot_open_id: str | None = None @staticmethod def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any: @@ -378,6 +379,15 @@ class FeishuChannel(BaseChannel): self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() + # Fetch bot's own open_id for accurate @mention matching + self._bot_open_id = await asyncio.get_running_loop().run_in_executor( + None, self._fetch_bot_open_id + ) + if self._bot_open_id: + logger.info("Feishu bot open_id: {}", self._bot_open_id) + else: + logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") + logger.info("Feishu bot started with WebSocket long connection") logger.info("No public IP required - using WebSocket to receive events") @@ -396,6 +406,20 @@ class FeishuChannel(BaseChannel): self._running = False logger.info("Feishu bot stopped") + def _fetch_bot_open_id(self) -> str | None: + """Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" + from lark_oapi.api.bot.v3 import GetBotInfoRequest + try: + request = GetBotInfoRequest.builder().build() + response = self._client.bot.v3.bot_info.get(request) + if response.success() and response.data and response.data.bot: + return getattr(response.data.bot, "open_id", None) + logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) + return None + except Exception as e: + logger.warning("Error fetching bot info: {}", e) + return None + def _is_bot_mentioned(self, message: Any) -> bool: """Check if the bot is @mentioned in the message.""" raw_content = message.content or "" @@ -406,8 +430,8 @@ class FeishuChannel(BaseChannel): mid = getattr(mention, "id", None) if not mid: continue - # Bot mentions have no user_id (None or "") but a valid open_id - if not getattr(mid, "user_id", None) and (getattr(mid, "open_id", None) or "").startswith("ou_"): + mention_open_id = getattr(mid, "open_id", None) or "" + if self._bot_open_id and mention_open_id == self._bot_open_id: return True return False From c88d97c6524d42ba4a690ffae8139b50a044912a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:49:00 +0000 Subject: [PATCH 36/85] fix: fall back to heuristic when bot open_id fetch fails If _fetch_bot_open_id returns None the exact-match path would silently disable all @mention detection. Restore the old heuristic as a fallback. Add 6 unit tests for _is_bot_mentioned covering both paths. Made-with: Cursor --- nanobot/channels/feishu.py | 9 +++- tests/channels/test_feishu_mention.py | 62 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/channels/test_feishu_mention.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 323b51fe5..7d75705a2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -431,8 +431,13 @@ class FeishuChannel(BaseChannel): if not mid: continue mention_open_id = getattr(mid, "open_id", None) or "" - if self._bot_open_id and mention_open_id == self._bot_open_id: - return True + if self._bot_open_id: + if mention_open_id == self._bot_open_id: + return True + else: + # Fallback heuristic when bot open_id is unavailable + if not getattr(mid, "user_id", None) and mention_open_id.startswith("ou_"): + return True return False def _is_group_message_for_bot(self, message: Any) -> bool: diff --git a/tests/channels/test_feishu_mention.py b/tests/channels/test_feishu_mention.py new file mode 100644 index 000000000..fb81f2294 --- /dev/null +++ b/tests/channels/test_feishu_mention.py @@ -0,0 +1,62 @@ +"""Tests for Feishu _is_bot_mentioned logic.""" + +from types import SimpleNamespace + +import pytest + +from nanobot.channels.feishu import FeishuChannel + + +def _make_channel(bot_open_id: str | None = None) -> FeishuChannel: + config = SimpleNamespace( + app_id="test_id", + app_secret="test_secret", + verification_token="", + event_encrypt_key="", + group_policy="mention", + ) + ch = FeishuChannel.__new__(FeishuChannel) + ch.config = config + ch._bot_open_id = bot_open_id + return ch + + +def _make_message(mentions=None, content="hello"): + return SimpleNamespace(content=content, mentions=mentions) + + +def _make_mention(open_id: str, user_id: str | None = None): + mid = SimpleNamespace(open_id=open_id, user_id=user_id) + return SimpleNamespace(id=mid) + + +class TestIsBotMentioned: + def test_exact_match_with_bot_open_id(self): + ch = _make_channel(bot_open_id="ou_bot123") + msg = _make_message(mentions=[_make_mention("ou_bot123")]) + assert ch._is_bot_mentioned(msg) is True + + def test_no_match_different_bot(self): + ch = _make_channel(bot_open_id="ou_bot123") + msg = _make_message(mentions=[_make_mention("ou_other_bot")]) + assert ch._is_bot_mentioned(msg) is False + + def test_at_all_always_matches(self): + ch = _make_channel(bot_open_id="ou_bot123") + msg = _make_message(content="@_all hello") + assert ch._is_bot_mentioned(msg) is True + + def test_fallback_heuristic_when_no_bot_open_id(self): + ch = _make_channel(bot_open_id=None) + msg = _make_message(mentions=[_make_mention("ou_some_bot", user_id=None)]) + assert ch._is_bot_mentioned(msg) is True + + def test_fallback_ignores_user_mentions(self): + ch = _make_channel(bot_open_id=None) + msg = _make_message(mentions=[_make_mention("ou_user", user_id="u_12345")]) + assert ch._is_bot_mentioned(msg) is False + + def test_no_mentions_returns_false(self): + ch = _make_channel(bot_open_id="ou_bot123") + msg = _make_message(mentions=None) + assert ch._is_bot_mentioned(msg) is False From 4e06e12ab60f4d856e6106dedab09526ae020a74 Mon Sep 17 00:00:00 2001 From: lang07123 Date: Tue, 31 Mar 2026 11:51:57 +0800 Subject: [PATCH 37/85] =?UTF-8?q?feat(provider):=20=E6=B7=BB=E5=8A=A0=20La?= =?UTF-8?q?ngfuse=20=E8=A7=82=E6=B5=8B=E5=B9=B3=E5=8F=B0=E7=9A=84=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(provider): 添加 Langfuse 观测平台的集成支持 --- nanobot/providers/openai_compat_provider.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index a216e9046..a06bfa237 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import hashlib +import importlib import os import secrets import string @@ -12,7 +13,15 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any import json_repair -from openai import AsyncOpenAI + +if os.environ.get("LANGFUSE_SECRET_KEY"): + LANGFUSE_AVAILABLE = importlib.util.find_spec("langfuse") is not None + if not LANGFUSE_AVAILABLE: + raise ImportError("Langfuse is not available; please install it with `pip install langfuse`") + + from langfuse.openai import AsyncOpenAI +else: + from openai import AsyncOpenAI from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest From f82b5a1b029de2a4cdd555da36b6953aac80df22 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:51:50 +0000 Subject: [PATCH 38/85] fix: graceful fallback when langfuse is not installed - Use import importlib.util (not bare importlib) for find_spec - Warn and fall back to standard openai instead of crashing with ImportError when LANGFUSE_SECRET_KEY is set but langfuse is missing Made-with: Cursor --- nanobot/providers/openai_compat_provider.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index a06bfa237..7149b95e1 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import hashlib -import importlib +import importlib.util import os import secrets import string @@ -14,13 +14,15 @@ from typing import TYPE_CHECKING, Any import json_repair -if os.environ.get("LANGFUSE_SECRET_KEY"): - LANGFUSE_AVAILABLE = importlib.util.find_spec("langfuse") is not None - if not LANGFUSE_AVAILABLE: - raise ImportError("Langfuse is not available; please install it with `pip install langfuse`") - +if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"): from langfuse.openai import AsyncOpenAI else: + if os.environ.get("LANGFUSE_SECRET_KEY"): + import logging + logging.getLogger(__name__).warning( + "LANGFUSE_SECRET_KEY is set but langfuse is not installed; " + "install with `pip install langfuse` to enable tracing" + ) from openai import AsyncOpenAI from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest From c40801c8f9e537411236a240bb75ef36bc1c8a9e Mon Sep 17 00:00:00 2001 From: Lim Ding Wen Date: Wed, 1 Apr 2026 01:49:03 +0800 Subject: [PATCH 39/85] fix(matrix): fix e2ee authentication --- README.md | 13 +++---- nanobot/channels/matrix.py | 71 ++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e8629f6b8..1858e1672 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,11 @@ pip install nanobot-ai[matrix] - You need: - `userId` (example: `@nanobot:matrix.org`) - - `accessToken` - - `deviceId` (recommended so sync tokens can be restored across restarts) -- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings. + - `password` + +(Note: `accessToken` and `deviceId` are still supported for legacy reasons, but +for reliable encryption, password login is recommended instead. If the +`password` is provided, `accessToken` and `deviceId` will be ignored.) **3. Configure** @@ -446,8 +448,7 @@ pip install nanobot-ai[matrix] "enabled": true, "homeserver": "https://matrix.org", "userId": "@nanobot:matrix.org", - "accessToken": "syt_xxx", - "deviceId": "NANOBOT01", + "password": "mypasswordhere", "e2eeEnabled": true, "allowFrom": ["@your_user:matrix.org"], "groupPolicy": "open", @@ -459,7 +460,7 @@ pip install nanobot-ai[matrix] } ``` -> Keep a persistent `matrix-store` and stable `deviceId` — encrypted session state is lost if these change across restarts. +> Keep a persistent `matrix-store` — encrypted session state is lost if these change across restarts. | Option | Description | |--------|-------------| diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index bc6d9398a..eef7f48ab 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,5 +1,6 @@ """Matrix (Element) channel — inbound sync + outbound message/media delivery.""" +import json import asyncio import logging import mimetypes @@ -21,6 +22,7 @@ try: DownloadError, InviteEvent, JoinError, + LoginResponse, MatrixRoom, MemoryDownloadResponse, RoomEncryptedMedia, @@ -203,8 +205,9 @@ class MatrixConfig(Base): enabled: bool = False homeserver: str = "https://matrix.org" - access_token: str = "" user_id: str = "" + password: str = "" + access_token: str = "" device_id: str = "" e2ee_enabled: bool = True sync_stop_grace_seconds: int = 2 @@ -256,17 +259,15 @@ class MatrixChannel(BaseChannel): self._running = True _configure_nio_logging_bridge() - store_path = get_data_dir() / "matrix-store" - store_path.mkdir(parents=True, exist_ok=True) + self.store_path = get_data_dir() / "matrix-store" + self.store_path.mkdir(parents=True, exist_ok=True) + self.session_path = self.store_path / "session.json" self.client = AsyncClient( homeserver=self.config.homeserver, user=self.config.user_id, - store_path=store_path, + store_path=self.store_path, config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled), ) - self.client.user_id = self.config.user_id - self.client.access_token = self.config.access_token - self.client.device_id = self.config.device_id self._register_event_callbacks() self._register_response_callbacks() @@ -274,13 +275,48 @@ class MatrixChannel(BaseChannel): if not self.config.e2ee_enabled: logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") - if self.config.device_id: + if self.config.password: + if self.config.access_token or self.config.device_id: + logger.warning("You are using password-based Matrix login. The access_token and device_id fields will be ignored.") + + create_new_session = True + if self.session_path.exists(): + logger.info(f"Found session.json at {self.session_path}; attempting to use existing session...") + try: + with open(self.session_path, "r", encoding="utf-8") as f: + session = json.load(f) + self.client.user_id = self.config.user_id + self.client.access_token = session["access_token"] + self.client.device_id = session["device_id"] + self.client.load_store() + logger.info("Successfully loaded from existing session") + create_new_session = False + except Exception as e: + logger.warning(f"Failed to load from existing session: {e}") + logger.info("Falling back to password login...") + + if create_new_session: + logger.info("Using password login...") + resp = await self.client.login(self.config.password) + if isinstance(resp, LoginResponse): + logger.info("Logged in using a password; saving details to disk") + self._write_session_to_disk(resp) + else: + logger.error(f"Failed to log in: {resp}") + + elif self.config.access_token and self.config.device_id: try: + self.client.user_id = self.config.user_id + self.client.access_token = self.config.access_token + self.client.device_id = self.config.device_id self.client.load_store() - except Exception: - logger.exception("Matrix store load failed; restart may replay recent messages.") + logger.info("Successfully loaded from existing session") + except Exception as e: + logger.warning(f"Failed to load from existing session: {e}") + else: - logger.warning("Matrix device_id empty; restart may replay recent messages.") + logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id, encryption may not work") + return self._sync_task = asyncio.create_task(self._sync_loop()) @@ -304,6 +340,19 @@ class MatrixChannel(BaseChannel): if self.client: await self.client.close() + def _write_session_to_disk(self, resp: LoginResponse) -> None: + """Save login session to disk for persistence across restarts.""" + session = { + "access_token": resp.access_token, + "device_id": resp.device_id, + } + try: + with open(self.session_path, "w", encoding="utf-8") as f: + json.dump(session, f, indent=2) + logger.info(f"session saved to {self.session_path}") + except Exception as e: + logger.warning(f"Failed to save session: {e}") + def _is_workspace_path_allowed(self, path: Path) -> bool: """Check path is inside workspace (when restriction enabled).""" if not self._restrict_to_workspace or not self._workspace: From 71061a0c8247e3f1254fd27cef35f4d8503f8045 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 05:56:25 +0000 Subject: [PATCH 40/85] fix: return on login failure, use loguru format strings, fix import order - Add missing return after failed password login to prevent starting sync loop with no credentials - Replace f-strings in logger calls with loguru {} placeholders - Fix stdlib import order (asyncio before json) Made-with: Cursor --- nanobot/channels/matrix.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index eef7f48ab..716a7f81a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,7 +1,7 @@ """Matrix (Element) channel — inbound sync + outbound message/media delivery.""" -import json import asyncio +import json import logging import mimetypes import time @@ -277,11 +277,11 @@ class MatrixChannel(BaseChannel): if self.config.password: if self.config.access_token or self.config.device_id: - logger.warning("You are using password-based Matrix login. The access_token and device_id fields will be ignored.") + logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.") create_new_session = True if self.session_path.exists(): - logger.info(f"Found session.json at {self.session_path}; attempting to use existing session...") + logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) try: with open(self.session_path, "r", encoding="utf-8") as f: session = json.load(f) @@ -292,7 +292,7 @@ class MatrixChannel(BaseChannel): logger.info("Successfully loaded from existing session") create_new_session = False except Exception as e: - logger.warning(f"Failed to load from existing session: {e}") + logger.warning("Failed to load from existing session: {}", e) logger.info("Falling back to password login...") if create_new_session: @@ -302,7 +302,8 @@ class MatrixChannel(BaseChannel): logger.info("Logged in using a password; saving details to disk") self._write_session_to_disk(resp) else: - logger.error(f"Failed to log in: {resp}") + logger.error("Failed to log in: {}", resp) + return elif self.config.access_token and self.config.device_id: try: @@ -312,10 +313,10 @@ class MatrixChannel(BaseChannel): self.client.load_store() logger.info("Successfully loaded from existing session") except Exception as e: - logger.warning(f"Failed to load from existing session: {e}") - + logger.warning("Failed to load from existing session: {}", e) + else: - logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id, encryption may not work") + logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work") return self._sync_task = asyncio.create_task(self._sync_loop()) @@ -349,9 +350,9 @@ class MatrixChannel(BaseChannel): try: with open(self.session_path, "w", encoding="utf-8") as f: json.dump(session, f, indent=2) - logger.info(f"session saved to {self.session_path}") + logger.info("Session saved to {}", self.session_path) except Exception as e: - logger.warning(f"Failed to save session: {e}") + logger.warning("Failed to save session: {}", e) def _is_workspace_path_allowed(self, path: Path) -> bool: """Check path is inside workspace (when restriction enabled).""" From 7b7a3e5748194e0a542ce6298281f5e192c815a0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:01:14 +0000 Subject: [PATCH 41/85] fix: media_paths NameError, import order, add error logging and tests - Move media_paths assignment before voice message handling to prevent NameError at runtime - Fix broken import layout in transcription.py (httpx/loguru after class) - Add error logging to OpenAITranscriptionProvider matching Groq style - Add regression tests for voice transcription and no-media fallback Made-with: Cursor --- nanobot/channels/whatsapp.py | 6 ++-- nanobot/providers/transcription.py | 12 ++++--- tests/channels/test_whatsapp_channel.py | 48 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 2d2552344..f0c07d105 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -236,6 +236,9 @@ class WhatsAppChannel(BaseChannel): sender_id = user_id.split("@")[0] if "@" in user_id else user_id logger.info("Sender {}", sender) + # Extract media paths (images/documents/videos downloaded by the bridge) + media_paths = data.get("media") or [] + # Handle voice transcription if it's a voice message if content == "[Voice Message]": if media_paths: @@ -249,9 +252,6 @@ class WhatsAppChannel(BaseChannel): else: content = "[Voice Message: Audio not available]" - # Extract media paths (images/documents/videos downloaded by the bridge) - media_paths = data.get("media") or [] - # Build content tags matching Telegram's pattern: [image: /path] or [file: /path] if media_paths: for p in media_paths: diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index d432d24fd..aca9693ee 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -3,6 +3,9 @@ import os from pathlib import Path +import httpx +from loguru import logger + class OpenAITranscriptionProvider: """Voice transcription provider using OpenAI's Whisper API.""" @@ -13,12 +16,13 @@ class OpenAITranscriptionProvider: async def transcribe(self, file_path: str | Path) -> str: if not self.api_key: + logger.warning("OpenAI API key not configured for transcription") return "" path = Path(file_path) if not path.exists(): + logger.error("Audio file not found: {}", file_path) return "" try: - import httpx async with httpx.AsyncClient() as client: with open(path, "rb") as f: files = {"file": (path.name, f), "model": (None, "whisper-1")} @@ -28,12 +32,10 @@ class OpenAITranscriptionProvider: ) response.raise_for_status() return response.json().get("text", "") - except Exception: + except Exception as e: + logger.error("OpenAI transcription error: {}", e) return "" -import httpx -from loguru import logger - class GroqTranscriptionProvider: """ diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index 8223fdff3..b1abb7b03 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -163,6 +163,54 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): assert kwargs["sender_id"] == "user" +@pytest.mark.asyncio +async def test_voice_message_transcription_uses_media_path(): + """Voice messages are transcribed when media path is available.""" + ch = WhatsAppChannel( + {"enabled": True, "transcriptionProvider": "openai", "transcriptionApiKey": "sk-test"}, + MagicMock(), + ) + ch._handle_message = AsyncMock() + ch.transcribe_audio = AsyncMock(return_value="Hello world") + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "v1", + "sender": "12345@s.whatsapp.net", + "pn": "", + "content": "[Voice Message]", + "timestamp": 1, + "media": ["/tmp/voice.ogg"], + }) + ) + + ch.transcribe_audio.assert_awaited_once_with("/tmp/voice.ogg") + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["content"].startswith("Hello world") + + +@pytest.mark.asyncio +async def test_voice_message_no_media_shows_not_available(): + """Voice messages without media produce a fallback placeholder.""" + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch._handle_message = AsyncMock() + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "v2", + "sender": "12345@s.whatsapp.net", + "pn": "", + "content": "[Voice Message]", + "timestamp": 1, + }) + ) + + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["content"] == "[Voice Message: Audio not available]" + + def test_load_or_create_bridge_token_persists_generated_secret(tmp_path): token_path = tmp_path / "whatsapp-auth" / "bridge-token" From 35dde8a30eb708067a5c6c6b09a8c2422fde1208 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:07:30 +0000 Subject: [PATCH 42/85] refactor: unify voice transcription config across all channels - Move transcriptionProvider to global channels config (not per-channel) - ChannelManager auto-resolves API key from matching provider config - BaseChannel gets transcription_provider attribute, no more getattr hack - Remove redundant transcription fields from WhatsAppConfig - Update README: document transcriptionProvider, update provider table Made-with: Cursor --- README.md | 8 +++++--- nanobot/channels/base.py | 4 ++-- nanobot/channels/manager.py | 12 ++++++++++-- nanobot/channels/whatsapp.py | 4 ---- nanobot/config/schema.py | 1 + tests/channels/test_whatsapp_channel.py | 7 +++---- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1858e1672..e42a6efe9 100644 --- a/README.md +++ b/README.md @@ -900,7 +900,7 @@ IMAP_PASSWORD=your-password-here ### Providers > [!TIP] -> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config. > - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link) > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers. @@ -916,9 +916,9 @@ IMAP_PASSWORD=your-password-here | `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) | -| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) | | `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | @@ -1233,6 +1233,7 @@ Global settings that apply to all channels. Configure under the `channels` secti "sendProgress": true, "sendToolHints": false, "sendMaxRetries": 3, + "transcriptionProvider": "groq", "telegram": { ... } } } @@ -1243,6 +1244,7 @@ Global settings that apply to all channels. Configure under the `channels` secti | `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | +| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | #### Retry Behavior diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index e0bb62c0f..dd29c0851 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -22,6 +22,7 @@ class BaseChannel(ABC): name: str = "base" display_name: str = "Base" + transcription_provider: str = "groq" transcription_api_key: str = "" def __init__(self, config: Any, bus: MessageBus): @@ -41,8 +42,7 @@ class BaseChannel(ABC): if not self.transcription_api_key: return "" try: - provider_name = getattr(self, "transcription_provider", "groq") - if provider_name == "openai": + if self.transcription_provider == "openai": from nanobot.providers.transcription import OpenAITranscriptionProvider provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key) else: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 1f26f4d7a..b52c38ca3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -39,7 +39,8 @@ class ChannelManager: """Initialize channels discovered via pkgutil scan + entry_points plugins.""" from nanobot.channels.registry import discover_all - groq_key = self.config.providers.groq.api_key + transcription_provider = self.config.channels.transcription_provider + transcription_key = self._resolve_transcription_key(transcription_provider) for name, cls in discover_all().items(): section = getattr(self.config.channels, name, None) @@ -54,7 +55,8 @@ class ChannelManager: continue try: channel = cls(section, self.bus) - channel.transcription_api_key = groq_key + channel.transcription_provider = transcription_provider + channel.transcription_api_key = transcription_key self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -62,6 +64,12 @@ class ChannelManager: self._validate_allow_from() + def _resolve_transcription_key(self, provider: str) -> str: + """Pick the API key for the configured transcription provider.""" + if provider == "openai": + return self.config.providers.openai.api_key + return self.config.providers.groq.api_key + def _validate_allow_from(self) -> None: for name, ch in self.channels.items(): if getattr(ch.config, "allow_from", None) == []: diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index f0c07d105..1b46d6e97 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -27,8 +27,6 @@ class WhatsAppConfig(Base): bridge_url: str = "ws://localhost:3001" bridge_token: str = "" allow_from: list[str] = Field(default_factory=list) - transcription_provider: str = "openai" # openai or groq - transcription_api_key: str = "" group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned @@ -77,8 +75,6 @@ class WhatsAppChannel(BaseChannel): self._ws = None self._connected = False self._processed_message_ids: OrderedDict[str, None] = OrderedDict() - self.transcription_api_key = config.transcription_api_key - self.transcription_provider = config.transcription_provider self._bridge_token: str | None = None def _effective_bridge_token(self) -> str: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index dfb91c528..f147434e7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -28,6 +28,7 @@ class ChannelsConfig(Base): 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("…")) send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) + transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai" class DreamConfig(Base): diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index b1abb7b03..f285e4dbe 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -166,10 +166,9 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): @pytest.mark.asyncio async def test_voice_message_transcription_uses_media_path(): """Voice messages are transcribed when media path is available.""" - ch = WhatsAppChannel( - {"enabled": True, "transcriptionProvider": "openai", "transcriptionApiKey": "sk-test"}, - MagicMock(), - ) + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch.transcription_provider = "openai" + ch.transcription_api_key = "sk-test" ch._handle_message = AsyncMock() ch.transcribe_audio = AsyncMock(return_value="Hello world") From 3bf1fa52253750b4d0f639e5765d3b713841ca09 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:10:08 +0000 Subject: [PATCH 43/85] feat: auto-fallback to other transcription provider on failure When the primary transcription provider fails (bad key, API error, etc.), automatically try the other provider if its API key is available. Made-with: Cursor --- nanobot/channels/base.py | 24 ++++++++++++++++++------ nanobot/channels/manager.py | 12 +++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index dd29c0851..27d0b07a8 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -24,6 +24,7 @@ class BaseChannel(ABC): display_name: str = "Base" transcription_provider: str = "groq" transcription_api_key: str = "" + _transcription_fallback_key: str = "" def __init__(self, config: Any, bus: MessageBus): """ @@ -38,19 +39,30 @@ class BaseChannel(ABC): self._running = False async def transcribe_audio(self, file_path: str | Path) -> str: - """Transcribe an audio file via Whisper (OpenAI or Groq). Returns empty string on failure.""" + """Transcribe an audio file via Whisper. Falls back to the other provider on failure.""" if not self.transcription_api_key: return "" + result = await self._try_transcribe(self.transcription_provider, self.transcription_api_key, file_path) + if result: + return result + fallback = "groq" if self.transcription_provider == "openai" else "openai" + if self._transcription_fallback_key: + logger.info("{}: trying {} fallback for transcription", self.name, fallback) + return await self._try_transcribe(fallback, self._transcription_fallback_key, file_path) + return "" + + async def _try_transcribe(self, provider: str, api_key: str, file_path: str | Path) -> str: + """Attempt transcription with a single provider. Returns empty string on failure.""" try: - if self.transcription_provider == "openai": + if provider == "openai": from nanobot.providers.transcription import OpenAITranscriptionProvider - provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key) + p = OpenAITranscriptionProvider(api_key=api_key) else: from nanobot.providers.transcription import GroqTranscriptionProvider - provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) - return await provider.transcribe(file_path) + p = GroqTranscriptionProvider(api_key=api_key) + return await p.transcribe(file_path) except Exception as e: - logger.warning("{}: audio transcription failed: {}", self.name, e) + logger.warning("{}: {} transcription failed: {}", self.name, provider, e) return "" async def login(self, force: bool = False) -> bool: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index b52c38ca3..d7bb4ef2d 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -41,6 +41,8 @@ class ChannelManager: transcription_provider = self.config.channels.transcription_provider transcription_key = self._resolve_transcription_key(transcription_provider) + fallback_provider = "groq" if transcription_provider == "openai" else "openai" + fallback_key = self._resolve_transcription_key(fallback_provider) for name, cls in discover_all().items(): section = getattr(self.config.channels, name, None) @@ -57,6 +59,7 @@ class ChannelManager: channel = cls(section, self.bus) channel.transcription_provider = transcription_provider channel.transcription_api_key = transcription_key + channel._transcription_fallback_key = fallback_key self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -66,9 +69,12 @@ class ChannelManager: def _resolve_transcription_key(self, provider: str) -> str: """Pick the API key for the configured transcription provider.""" - if provider == "openai": - return self.config.providers.openai.api_key - return self.config.providers.groq.api_key + try: + if provider == "openai": + return self.config.providers.openai.api_key + return self.config.providers.groq.api_key + except AttributeError: + return "" def _validate_allow_from(self) -> None: for name, ch in self.channels.items(): From 019eaff2251c940dc10af2bfbe197eb7c2f9eb07 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:13:43 +0000 Subject: [PATCH 44/85] simplify: remove transcription fallback, respect explicit config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configured provider is the only one used — no silent fallback. Made-with: Cursor --- nanobot/channels/base.py | 24 ++++++------------------ nanobot/channels/manager.py | 3 --- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 27d0b07a8..dd29c0851 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -24,7 +24,6 @@ class BaseChannel(ABC): display_name: str = "Base" transcription_provider: str = "groq" transcription_api_key: str = "" - _transcription_fallback_key: str = "" def __init__(self, config: Any, bus: MessageBus): """ @@ -39,30 +38,19 @@ class BaseChannel(ABC): self._running = False async def transcribe_audio(self, file_path: str | Path) -> str: - """Transcribe an audio file via Whisper. Falls back to the other provider on failure.""" + """Transcribe an audio file via Whisper (OpenAI or Groq). Returns empty string on failure.""" if not self.transcription_api_key: return "" - result = await self._try_transcribe(self.transcription_provider, self.transcription_api_key, file_path) - if result: - return result - fallback = "groq" if self.transcription_provider == "openai" else "openai" - if self._transcription_fallback_key: - logger.info("{}: trying {} fallback for transcription", self.name, fallback) - return await self._try_transcribe(fallback, self._transcription_fallback_key, file_path) - return "" - - async def _try_transcribe(self, provider: str, api_key: str, file_path: str | Path) -> str: - """Attempt transcription with a single provider. Returns empty string on failure.""" try: - if provider == "openai": + if self.transcription_provider == "openai": from nanobot.providers.transcription import OpenAITranscriptionProvider - p = OpenAITranscriptionProvider(api_key=api_key) + provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key) else: from nanobot.providers.transcription import GroqTranscriptionProvider - p = GroqTranscriptionProvider(api_key=api_key) - return await p.transcribe(file_path) + provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) + return await provider.transcribe(file_path) except Exception as e: - logger.warning("{}: {} transcription failed: {}", self.name, provider, e) + logger.warning("{}: audio transcription failed: {}", self.name, e) return "" async def login(self, force: bool = False) -> bool: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index d7bb4ef2d..aaec5e335 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -41,8 +41,6 @@ class ChannelManager: transcription_provider = self.config.channels.transcription_provider transcription_key = self._resolve_transcription_key(transcription_provider) - fallback_provider = "groq" if transcription_provider == "openai" else "openai" - fallback_key = self._resolve_transcription_key(fallback_provider) for name, cls in discover_all().items(): section = getattr(self.config.channels, name, None) @@ -59,7 +57,6 @@ class ChannelManager: channel = cls(section, self.bus) channel.transcription_provider = transcription_provider channel.transcription_api_key = transcription_key - channel._transcription_fallback_key = fallback_key self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: From 897d5a7e584370974407ab0df2f7d97722130686 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:19:06 +0000 Subject: [PATCH 45/85] test: add regression tests for JID suffix classification and LID cache Made-with: Cursor --- tests/channels/test_whatsapp_channel.py | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index f285e4dbe..b61033677 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -163,6 +163,60 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): assert kwargs["sender_id"] == "user" +@pytest.mark.asyncio +async def test_sender_id_prefers_phone_jid_over_lid(): + """sender_id should resolve to phone number when @s.whatsapp.net JID is present.""" + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch._handle_message = AsyncMock() + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "lid1", + "sender": "ABC123@lid.whatsapp.net", + "pn": "5551234@s.whatsapp.net", + "content": "hi", + "timestamp": 1, + }) + ) + + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["sender_id"] == "5551234" + + +@pytest.mark.asyncio +async def test_lid_to_phone_cache_resolves_lid_only_messages(): + """When only LID is present, a cached LID→phone mapping should be used.""" + ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch._handle_message = AsyncMock() + + # First message: both phone and LID → builds cache + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "c1", + "sender": "LID99@lid.whatsapp.net", + "pn": "5559999@s.whatsapp.net", + "content": "first", + "timestamp": 1, + }) + ) + # Second message: only LID, no phone + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "c2", + "sender": "LID99@lid.whatsapp.net", + "pn": "", + "content": "second", + "timestamp": 2, + }) + ) + + second_kwargs = ch._handle_message.await_args_list[1].kwargs + assert second_kwargs["sender_id"] == "5559999" + + @pytest.mark.asyncio async def test_voice_message_transcription_uses_media_path(): """Voice messages are transcribed when media path is available.""" From bdec2637aefc3042f234bd3dda9f4abee2869f32 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:39:23 +0000 Subject: [PATCH 46/85] test: add regression test for oversized stream-end splitting Made-with: Cursor --- tests/channels/test_telegram_channel.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 1f25dcfa7..7bc212804 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -385,6 +385,32 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None: assert "123" not in channel._stream_bufs +@pytest.mark.asyncio +async def test_send_delta_stream_end_splits_oversized_reply() -> None: + """Final streamed reply exceeding Telegram limit is split into chunks.""" + from nanobot.channels.telegram import TELEGRAM_MAX_MESSAGE_LEN + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + channel._app.bot.edit_message_text = AsyncMock() + channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99)) + + oversized = "x" * (TELEGRAM_MAX_MESSAGE_LEN + 500) + channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0) + + await channel.send_delta("123", "", {"_stream_end": True}) + + channel._app.bot.edit_message_text.assert_called_once() + edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "") + assert len(edit_text) <= TELEGRAM_MAX_MESSAGE_LEN + + channel._app.bot.send_message.assert_called_once() + assert "123" not in channel._stream_bufs + + @pytest.mark.asyncio async def test_send_delta_new_stream_id_replaces_stale_buffer() -> None: channel = TelegramChannel( From d0527a8cf4fb85e6a5bb0b427b3325fb615df3d0 Mon Sep 17 00:00:00 2001 From: Ben Lenarts Date: Sun, 5 Apr 2026 08:06:28 +0200 Subject: [PATCH 47/85] feat(email): add attachment extraction support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save inbound email attachments to the media directory with configurable MIME type filtering (glob patterns like "image/*"), per-attachment size limits, and max attachment count. Filenames are sanitized to prevent path traversal. Controlled by allowed_attachment_types — empty (default) means disabled, non-empty enables extraction for matching types. --- nanobot/channels/email.py | 81 ++++++++++ tests/channels/test_email_channel.py | 222 +++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index bee2ceccd..15edff490 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -1,6 +1,7 @@ """Email channel implementation using IMAP polling + SMTP replies.""" import asyncio +from fnmatch import fnmatch import html import imaplib import re @@ -14,13 +15,17 @@ from email.parser import BytesParser from email.utils import parseaddr from typing import Any +from pathlib import Path + from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base +from nanobot.utils.helpers import safe_filename class EmailConfig(Base): @@ -55,6 +60,11 @@ class EmailConfig(Base): verify_dkim: bool = True # Require Authentication-Results with dkim=pass verify_spf: bool = True # Require Authentication-Results with spf=pass + # Attachment handling — set allowed types to enable (e.g. ["application/pdf", "image/*"], or ["*"] for all) + allowed_attachment_types: list[str] = Field(default_factory=list) + max_attachment_size: int = 2_000_000 # 2MB per attachment + max_attachments_per_email: int = 5 + class EmailChannel(BaseChannel): """ @@ -153,6 +163,7 @@ class EmailChannel(BaseChannel): sender_id=sender, chat_id=sender, content=item["content"], + media=item.get("media") or None, metadata=item.get("metadata", {}), ) except Exception as e: @@ -404,6 +415,20 @@ class EmailChannel(BaseChannel): f"{body}" ) + # --- Attachment extraction --- + attachment_paths: list[str] = [] + if self.config.allowed_attachment_types: + saved = self._extract_attachments( + parsed, + uid or "noid", + allowed_types=self.config.allowed_attachment_types, + max_size=self.config.max_attachment_size, + max_count=self.config.max_attachments_per_email, + ) + for p in saved: + attachment_paths.append(str(p)) + content += f"\n[attachment: {p.name} — saved to {p}]" + metadata = { "message_id": message_id, "subject": subject, @@ -418,6 +443,7 @@ class EmailChannel(BaseChannel): "message_id": message_id, "content": content, "metadata": metadata, + "media": attachment_paths, } ) @@ -537,6 +563,61 @@ class EmailChannel(BaseChannel): dkim_pass = True return spf_pass, dkim_pass + @classmethod + def _extract_attachments( + cls, + msg: Any, + uid: str, + *, + allowed_types: list[str], + max_size: int, + max_count: int, + ) -> list[Path]: + """Extract and save email attachments to the media directory. + + Returns list of saved file paths. + """ + if not msg.is_multipart(): + return [] + + saved: list[Path] = [] + media_dir = get_media_dir("email") + + for part in msg.walk(): + if len(saved) >= max_count: + break + if part.get_content_disposition() != "attachment": + continue + + content_type = part.get_content_type() + if not any(fnmatch(content_type, pat) for pat in allowed_types): + logger.debug("Email attachment skipped (type {}): not in allowed list", content_type) + continue + + payload = part.get_payload(decode=True) + if payload is None: + continue + if len(payload) > max_size: + logger.warning( + "Email attachment skipped: size {} exceeds limit {}", + len(payload), + max_size, + ) + continue + + raw_name = part.get_filename() or "attachment" + sanitized = safe_filename(raw_name) or "attachment" + dest = media_dir / f"{uid}_{sanitized}" + + try: + dest.write_bytes(payload) + saved.append(dest) + logger.info("Email attachment saved: {}", dest) + except Exception as exc: + logger.warning("Failed to save email attachment {}: {}", dest, exc) + + return saved + @staticmethod def _html_to_text(raw_html: str) -> str: text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE) diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 2d0e33ce3..6d6d2f74f 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -1,5 +1,6 @@ from email.message import EmailMessage from datetime import date +from pathlib import Path import imaplib import pytest @@ -650,3 +651,224 @@ def test_check_authentication_results_method() -> None: spf, dkim = EmailChannel._check_authentication_results(parsed) assert spf is False assert dkim is True + + +# --------------------------------------------------------------------------- +# Attachment extraction tests +# --------------------------------------------------------------------------- + + +def _make_raw_email_with_attachment( + from_addr: str = "alice@example.com", + subject: str = "With attachment", + body: str = "See attached.", + attachment_name: str = "doc.pdf", + attachment_content: bytes = b"%PDF-1.4 fake pdf content", + attachment_mime: str = "application/pdf", + auth_results: str | None = None, +) -> bytes: + msg = EmailMessage() + msg["From"] = from_addr + msg["To"] = "bot@example.com" + msg["Subject"] = subject + msg["Message-ID"] = "" + if auth_results: + msg["Authentication-Results"] = auth_results + msg.set_content(body) + maintype, subtype = attachment_mime.split("/", 1) + msg.add_attachment( + attachment_content, + maintype=maintype, + subtype=subtype, + filename=attachment_name, + ) + return msg.as_bytes() + + +def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None: + """PDF attachment is saved to media dir and path returned in media list.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment() + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config(allowed_attachment_types=["application/pdf"], verify_dkim=False, verify_spf=False) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert len(items[0]["media"]) == 1 + saved_path = Path(items[0]["media"][0]) + assert saved_path.exists() + assert saved_path.read_bytes() == b"%PDF-1.4 fake pdf content" + assert "500_doc.pdf" in saved_path.name + assert "[attachment:" in items[0]["content"] + + +def test_extract_attachments_disabled_by_default(monkeypatch) -> None: + """With no allowed_attachment_types (default), no attachments are extracted.""" + raw = _make_raw_email_with_attachment() + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config(verify_dkim=False, verify_spf=False) + assert cfg.allowed_attachment_types == [] + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert items[0]["media"] == [] + assert "[attachment:" not in items[0]["content"] + + +def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None: + """Non-allowed MIME types are skipped.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment( + attachment_name="image.png", + attachment_content=b"\x89PNG fake", + attachment_mime="image/png", + ) + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config( + allowed_attachment_types=["application/pdf"], + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert items[0]["media"] == [] + + +def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypatch) -> None: + """Empty allowed_attachment_types means no types are accepted.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment( + attachment_name="image.png", + attachment_content=b"\x89PNG fake", + attachment_mime="image/png", + ) + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config( + allowed_attachment_types=[], + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert items[0]["media"] == [] + + +def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None: + """Glob patterns like 'image/*' match attachment MIME types.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment( + attachment_name="photo.jpg", + attachment_content=b"\xff\xd8\xff fake jpeg", + attachment_mime="image/jpeg", + ) + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config( + allowed_attachment_types=["image/*"], + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert len(items[0]["media"]) == 1 + + +def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None: + """Attachments exceeding max_attachment_size are skipped.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment( + attachment_content=b"x" * 1000, + ) + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config( + allowed_attachment_types=["*"], + max_attachment_size=500, + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert items[0]["media"] == [] + + +def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None: + """Only max_attachments_per_email are saved.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + # Build email with 3 attachments + msg = EmailMessage() + msg["From"] = "alice@example.com" + msg["To"] = "bot@example.com" + msg["Subject"] = "Many attachments" + msg["Message-ID"] = "" + msg.set_content("See attached.") + for i in range(3): + msg.add_attachment( + f"content {i}".encode(), + maintype="application", + subtype="pdf", + filename=f"doc{i}.pdf", + ) + raw = msg.as_bytes() + + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config( + allowed_attachment_types=["*"], + max_attachments_per_email=2, + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert len(items[0]["media"]) == 2 + + +def test_extract_attachments_sanitizes_filename(tmp_path, monkeypatch) -> None: + """Path traversal in filenames is neutralized.""" + monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) + + raw = _make_raw_email_with_attachment( + attachment_name="../../../etc/passwd", + ) + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + cfg = _make_config(allowed_attachment_types=["*"], verify_dkim=False, verify_spf=False) + channel = EmailChannel(cfg, MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert len(items[0]["media"]) == 1 + saved_path = Path(items[0]["media"][0]) + # File must be inside the media dir, not escaped via path traversal + assert saved_path.parent == tmp_path From 0c4b1a4a0ec2441cf8cc86f3aa05cf298ae1cfa3 Mon Sep 17 00:00:00 2001 From: Ben Lenarts Date: Sun, 5 Apr 2026 08:06:35 +0200 Subject: [PATCH 48/85] docs(email): document attachment extraction options in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e42a6efe9..acec0642b 100644 --- a/README.md +++ b/README.md @@ -721,6 +721,9 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl > - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone. > - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly. > - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. +> - `allowedAttachmentTypes`: Save inbound attachments matching these MIME types — `["*"]` for all, e.g. `["application/pdf", "image/*"]` (default `[]` = disabled). +> - `maxAttachmentSize`: Max size per attachment in bytes (default `2000000` / 2MB). +> - `maxAttachmentsPerEmail`: Max attachments to save per email (default `5`). ```json { From b98a0aabfc82eca79c196d24493824424bd6078d Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 06:45:40 +0000 Subject: [PATCH 49/85] style: fix stdlib import ordering in email.py Made-with: Cursor --- nanobot/channels/email.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 15edff490..f0fcdf9a9 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -1,7 +1,6 @@ """Email channel implementation using IMAP polling + SMTP replies.""" import asyncio -from fnmatch import fnmatch import html import imaplib import re @@ -13,9 +12,9 @@ from email.header import decode_header, make_header from email.message import EmailMessage from email.parser import BytesParser from email.utils import parseaddr -from typing import Any - +from fnmatch import fnmatch from pathlib import Path +from typing import Any from loguru import logger from pydantic import Field From c0e161de23ac74277672f658245d24a7649e01d1 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 07:07:27 +0000 Subject: [PATCH 50/85] docs: add attachment example to email config JSON Made-with: Cursor --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acec0642b..06218b1a4 100644 --- a/README.md +++ b/README.md @@ -740,7 +740,8 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl "smtpUsername": "my-nanobot@gmail.com", "smtpPassword": "your-app-password", "fromAddress": "my-nanobot@gmail.com", - "allowFrom": ["your-real-email@gmail.com"] + "allowFrom": ["your-real-email@gmail.com"], + "allowedAttachmentTypes": ["application/pdf", "image/*"] } } } From bd94454b91baca6bb9193d105ac6de78b695caf4 Mon Sep 17 00:00:00 2001 From: PlayDustinDB <741815398@qq.com> Date: Mon, 6 Apr 2026 15:15:10 +0800 Subject: [PATCH 51/85] feat(think): adjust thinking method for dashscope and modelark --- nanobot/providers/openai_compat_provider.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 7149b95e1..b49a0b32c 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -297,6 +297,23 @@ class OpenAICompatProvider(LLMProvider): if reasoning_effort: kwargs["reasoning_effort"] = reasoning_effort + # Provider-specific thinking parameters + if spec: + # Refer: https://docs.byteplus.com/en/docs/ModelArk/1449737#adjust-reasoning-length + # The agent will stop thinking if reasoning_effort is minimal or None. Otherwise, it will think. + thinking_enabled = ( + reasoning_effort is not None and reasoning_effort.lower() != "minimal" + ) + if spec.name == "dashscope": + # Qwen: extra_body={"enable_thinking": True/False} + kwargs["extra_body"] = {"enable_thinking": thinking_enabled} + elif spec.name in ("volcengine", "volcengine_coding_plan"): + # VolcEngine/Byteplus ModelArk: extra_body={"thinking": {"type": "enabled"/"disabled"}} + kwargs["extra_body"] = { + "thinking": {"type": "enabled" if thinking_enabled else "disabled"} + } + + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" From ebf29d87ae38365bfe94ca474212ff4e2c21a049 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 08:11:14 +0000 Subject: [PATCH 52/85] fix: include byteplus providers, guard None reasoning_effort, merge extra_body - Add byteplus and byteplus_coding_plan to thinking param providers - Only send extra_body when reasoning_effort is explicitly set - Use setdefault().update() to avoid clobbering existing extra_body - Add 7 regression tests for thinking params Made-with: Cursor --- nanobot/providers/openai_compat_provider.py | 27 +++++------ tests/providers/test_litellm_kwargs.py | 51 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index b49a0b32c..950907212 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -297,22 +297,23 @@ class OpenAICompatProvider(LLMProvider): if reasoning_effort: kwargs["reasoning_effort"] = reasoning_effort - # Provider-specific thinking parameters - if spec: - # Refer: https://docs.byteplus.com/en/docs/ModelArk/1449737#adjust-reasoning-length - # The agent will stop thinking if reasoning_effort is minimal or None. Otherwise, it will think. - thinking_enabled = ( - reasoning_effort is not None and reasoning_effort.lower() != "minimal" - ) + # Provider-specific thinking parameters. + # Only sent when reasoning_effort is explicitly configured so that + # the provider default is preserved otherwise. + if spec and reasoning_effort is not None: + thinking_enabled = reasoning_effort.lower() != "minimal" + extra: dict[str, Any] | None = None if spec.name == "dashscope": - # Qwen: extra_body={"enable_thinking": True/False} - kwargs["extra_body"] = {"enable_thinking": thinking_enabled} - elif spec.name in ("volcengine", "volcengine_coding_plan"): - # VolcEngine/Byteplus ModelArk: extra_body={"thinking": {"type": "enabled"/"disabled"}} - kwargs["extra_body"] = { + extra = {"enable_thinking": thinking_enabled} + elif spec.name in ( + "volcengine", "volcengine_coding_plan", + "byteplus", "byteplus_coding_plan", + ): + extra = { "thinking": {"type": "enabled" if thinking_enabled else "disabled"} } - + if extra: + kwargs.setdefault("extra_body", {}).update(extra) if tools: kwargs["tools"] = tools diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 1be505872..2e885e165 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -307,3 +307,54 @@ async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) assert result.finish_reason == "error" assert result.content is not None assert "stream stalled" in result.content + + +# --------------------------------------------------------------------------- +# Provider-specific thinking parameters (extra_body) +# --------------------------------------------------------------------------- + +def _build_kwargs_for(provider_name: str, model: str, reasoning_effort=None): + spec = find_by_name(provider_name) + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model=model, spec=spec) + return p._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, model=model, max_tokens=1024, temperature=0.7, + reasoning_effort=reasoning_effort, tool_choice=None, + ) + + +def test_dashscope_thinking_enabled_with_reasoning_effort() -> None: + kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="medium") + assert kw["extra_body"] == {"enable_thinking": True} + + +def test_dashscope_thinking_disabled_for_minimal() -> None: + kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal") + assert kw["extra_body"] == {"enable_thinking": False} + + +def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None: + kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_volcengine_thinking_enabled() -> None: + kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro", reasoning_effort="high") + assert kw["extra_body"] == {"thinking": {"type": "enabled"}} + + +def test_byteplus_thinking_disabled_for_minimal() -> None: + kw = _build_kwargs_for("byteplus", "doubao-seed-2-0-pro", reasoning_effort="minimal") + assert kw["extra_body"] == {"thinking": {"type": "disabled"}} + + +def test_byteplus_no_extra_body_when_reasoning_effort_none() -> None: + kw = _build_kwargs_for("byteplus", "doubao-seed-2-0-pro", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_openai_no_thinking_extra_body() -> None: + """Non-thinking providers should never get extra_body for thinking.""" + kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium") + assert "extra_body" not in kw From d99331ad31842772f6a5b039d53d9e3b1e5fd62e Mon Sep 17 00:00:00 2001 From: dengjingren Date: Mon, 6 Apr 2026 16:06:32 +0800 Subject: [PATCH 53/85] feat(docker): add nanobot-api service with isolated workspace - Add nanobot-api service (OpenAI-compatible HTTP API on port 8900) - Uses isolated workspace (/root/.nanobot/api-workspace) to avoid session/memory conflicts with nanobot-gateway Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2b2c9acd1..139dfe2ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,12 +23,29 @@ services: deploy: resources: limits: - cpus: '1' + cpus: "1" memory: 1G reservations: - cpus: '0.25' + cpus: "0.25" memory: 256M - + + nanobot-api: + container_name: nanobot-api + <<: *common-config + command: + ["serve", "--host", "0.0.0.0", "-w", "/root/.nanobot/api-workspace"] + restart: unless-stopped + ports: + - 8900:8900 + deploy: + resources: + limits: + cpus: "1" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M + nanobot-cli: <<: *common-config profiles: From 634261f07a67f6b2de606522a89a58958842f5f4 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 08:14:26 +0000 Subject: [PATCH 54/85] fix: correct api-workspace path for non-root container user The Dockerfile runs as user nanobot (HOME=/home/nanobot), not root. Made-with: Cursor --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 139dfe2ff..662d2f0d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: container_name: nanobot-api <<: *common-config command: - ["serve", "--host", "0.0.0.0", "-w", "/root/.nanobot/api-workspace"] + ["serve", "--host", "0.0.0.0", "-w", "/home/nanobot/.nanobot/api-workspace"] restart: unless-stopped ports: - 8900:8900 From d108879b48d73261bc4c5466fe6ba32bd71a21f5 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 08:16:13 +0000 Subject: [PATCH 55/85] security: bind api port to localhost by default Prevents accidental exposure to the public internet. Users who need external access can change to 0.0.0.0:8900:8900 explicitly. Made-with: Cursor --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 662d2f0d6..21beb1c6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: ["serve", "--host", "0.0.0.0", "-w", "/home/nanobot/.nanobot/api-workspace"] restart: unless-stopped ports: - - 8900:8900 + - 127.0.0.1:8900:8900 deploy: resources: limits: From aeba9a23e63939b276b125c304e69533e86ce0be Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 08:35:02 +0000 Subject: [PATCH 56/85] refactor: remove dead _error_response wrapper in Anthropic provider Fold _error_response back into _handle_error to match OpenAI/Azure convention. Update all call sites and tests accordingly. Made-with: Cursor --- nanobot/providers/anthropic_provider.py | 10 +++------- tests/providers/test_provider_error_metadata.py | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 4b90080a0..7ed94a9ba 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -98,7 +98,7 @@ class AnthropicProvider(LLMProvider): return value if value > 0 else None @classmethod - def _error_response(cls, e: Exception) -> LLMResponse: + def _handle_error(cls, e: Exception) -> LLMResponse: response = getattr(e, "response", None) headers = getattr(response, "headers", None) payload = ( @@ -505,10 +505,6 @@ class AnthropicProvider(LLMProvider): # Public API # ------------------------------------------------------------------ - @classmethod - def _handle_error(cls, e: Exception) -> LLMResponse: - return cls._error_response(e) - async def chat( self, messages: list[dict[str, Any]], @@ -527,7 +523,7 @@ class AnthropicProvider(LLMProvider): response = await self._client.messages.create(**kwargs) return self._parse_response(response) except Exception as e: - return self._error_response(e) + return self._handle_error(e) async def chat_stream( self, @@ -573,7 +569,7 @@ class AnthropicProvider(LLMProvider): error_kind="timeout", ) except Exception as e: - return self._error_response(e) + return self._handle_error(e) def get_default_model(self) -> str: return self.default_model diff --git a/tests/providers/test_provider_error_metadata.py b/tests/providers/test_provider_error_metadata.py index 27f0eb0f1..ea2532acf 100644 --- a/tests/providers/test_provider_error_metadata.py +++ b/tests/providers/test_provider_error_metadata.py @@ -50,7 +50,7 @@ def test_openai_handle_error_marks_timeout_kind() -> None: assert response.error_kind == "timeout" -def test_anthropic_error_response_extracts_structured_metadata() -> None: +def test_anthropic_handle_error_extracts_structured_metadata() -> None: class FakeStatusError(Exception): pass @@ -62,7 +62,7 @@ def test_anthropic_error_response_extracts_structured_metadata() -> None: ) err.body = {"type": "error", "error": {"type": "rate_limit_error"}} - response = AnthropicProvider._error_response(err) + response = AnthropicProvider._handle_error(err) assert response.finish_reason == "error" assert response.error_status_code == 408 @@ -71,11 +71,11 @@ def test_anthropic_error_response_extracts_structured_metadata() -> None: assert response.error_should_retry is True -def test_anthropic_error_response_marks_connection_kind() -> None: +def test_anthropic_handle_error_marks_connection_kind() -> None: class FakeConnectionError(Exception): pass - response = AnthropicProvider._error_response(FakeConnectionError("connection")) + response = AnthropicProvider._handle_error(FakeConnectionError("connection")) assert response.finish_reason == "error" assert response.error_kind == "connection" From 35f53a721d372a36ffe503be4918cb59c3a930a1 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 08:44:52 +0000 Subject: [PATCH 57/85] refactor: consolidate _parse_retry_after_headers into base class Merge the three retry-after header parsers (base, OpenAI, Anthropic) into a single _extract_retry_after_from_headers on LLMProvider that handles retry-after-ms, case-insensitive lookup, and HTTP date. Remove the per-provider _parse_retry_after_headers duplicates and their now-unused email.utils / time imports. Add test for retry-after-ms. Made-with: Cursor --- nanobot/providers/anthropic_provider.py | 47 +-------------------- nanobot/providers/base.py | 30 +++++++++---- nanobot/providers/openai_compat_provider.py | 36 +--------------- tests/providers/test_provider_retry.py | 8 ++++ 4 files changed, 32 insertions(+), 89 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 7ed94a9ba..e389b51ed 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -3,12 +3,10 @@ from __future__ import annotations import asyncio -import email.utils import os import re import secrets import string -import time from collections.abc import Awaitable, Callable from typing import Any @@ -54,49 +52,6 @@ class AnthropicProvider(LLMProvider): client_kw["max_retries"] = 0 self._client = AsyncAnthropic(**client_kw) - @staticmethod - def _parse_retry_after_headers(headers: Any) -> float | None: - if headers is None: - return None - - def _header_value(name: str) -> Any: - if hasattr(headers, "get"): - value = headers.get(name) or headers.get(name.title()) - if value is not None: - return value - if isinstance(headers, dict): - for key, value in headers.items(): - if isinstance(key, str) and key.lower() == name.lower(): - return value - return None - - try: - retry_ms = _header_value("retry-after-ms") - if retry_ms is not None: - value = float(retry_ms) / 1000.0 - if value > 0: - return value - except (TypeError, ValueError): - pass - - retry_after = _header_value("retry-after") - try: - if retry_after is not None: - value = float(retry_after) - if value > 0: - return value - except (TypeError, ValueError): - pass - - if retry_after is None: - return None - retry_date_tuple = email.utils.parsedate_tz(retry_after) - if retry_date_tuple is None: - return None - retry_date = email.utils.mktime_tz(retry_date_tuple) - value = float(retry_date - time.time()) - return value if value > 0 else None - @classmethod def _handle_error(cls, e: Exception) -> LLMResponse: response = getattr(e, "response", None) @@ -115,7 +70,7 @@ class AnthropicProvider(LLMProvider): payload = None payload_text = payload if isinstance(payload, str) else str(payload) if payload is not None else "" msg = f"Error: {payload_text.strip()[:500]}" if payload_text.strip() else f"Error calling LLM: {e}" - retry_after = cls._parse_retry_after_headers(headers) + retry_after = cls._extract_retry_after_from_headers(headers) if retry_after is None: retry_after = LLMProvider._extract_retry_after(msg) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index da229dcc3..d5833c9ae 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -524,14 +524,28 @@ class LLMProvider(ABC): def _extract_retry_after_from_headers(cls, headers: Any) -> float | None: if not headers: return None - retry_after: Any = None - if hasattr(headers, "get"): - retry_after = headers.get("retry-after") or headers.get("Retry-After") - if retry_after is None and isinstance(headers, dict): - for key, value in headers.items(): - if isinstance(key, str) and key.lower() == "retry-after": - retry_after = value - break + + def _header_value(name: str) -> Any: + if hasattr(headers, "get"): + value = headers.get(name) or headers.get(name.title()) + if value is not None: + return value + if isinstance(headers, dict): + for key, value in headers.items(): + if isinstance(key, str) and key.lower() == name.lower(): + return value + return None + + try: + retry_ms = _header_value("retry-after-ms") + if retry_ms is not None: + value = float(retry_ms) / 1000.0 + if value > 0: + return value + except (TypeError, ValueError): + pass + + retry_after = _header_value("retry-after") if retry_after is None: return None retry_after_text = str(retry_after).strip() diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index cb662556a..706268585 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -3,13 +3,11 @@ from __future__ import annotations import asyncio -import email.utils import hashlib import importlib.util import os import secrets import string -import time import uuid from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -636,38 +634,6 @@ class OpenAICompatProvider(LLMProvider): reasoning_content="".join(reasoning_parts) or None, ) - @staticmethod - def _parse_retry_after_headers(headers: Any) -> float | None: - if headers is None: - return None - - try: - retry_ms = headers.get("retry-after-ms") - if retry_ms is not None: - value = float(retry_ms) / 1000.0 - if value > 0: - return value - except (TypeError, ValueError): - pass - - retry_after = headers.get("retry-after") - try: - if retry_after is not None: - value = float(retry_after) - if value > 0: - return value - except (TypeError, ValueError): - pass - - if retry_after is None: - return None - retry_date_tuple = email.utils.parsedate_tz(retry_after) - if retry_date_tuple is None: - return None - retry_date = email.utils.mktime_tz(retry_date_tuple) - value = float(retry_date - time.time()) - return value if value > 0 else None - @classmethod def _extract_error_metadata(cls, e: Exception) -> dict[str, Any]: response = getattr(e, "response", None) @@ -712,7 +678,7 @@ class OpenAICompatProvider(LLMProvider): "error_kind": error_kind, "error_type": error_type, "error_code": error_code, - "error_retry_after_s": cls._parse_retry_after_headers(headers), + "error_retry_after_s": cls._extract_retry_after_from_headers(headers), "error_should_retry": should_retry, } diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index ad8048162..78c2a791e 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -254,6 +254,14 @@ def test_extract_retry_after_from_headers_supports_numeric_and_http_date() -> No ) == 0.1 +def test_extract_retry_after_from_headers_supports_retry_after_ms() -> None: + assert LLMProvider._extract_retry_after_from_headers({"retry-after-ms": "250"}) == 0.25 + assert LLMProvider._extract_retry_after_from_headers({"Retry-After-Ms": "1000"}) == 1.0 + assert LLMProvider._extract_retry_after_from_headers( + {"retry-after-ms": "500", "retry-after": "10"}, + ) == 0.5 + + @pytest.mark.asyncio async def test_chat_with_retry_prefers_structured_retry_after_when_present(monkeypatch) -> None: provider = ScriptedProvider([ From 84f0571e0d1773947d06b3f1bfd06da8ecc80efb Mon Sep 17 00:00:00 2001 From: yanghan-cyber Date: Mon, 6 Apr 2026 18:47:38 +0800 Subject: [PATCH 58/85] fix(status): use correct AgentLoop attribute for web search config The /status command tried to access web search config via `loop.config.tools.web.search`, but AgentLoop has no `config` attribute. This caused the search usage lookup to silently return None, so web search provider usage was never displayed. Fix: use `loop.web_config.search` which is the actual attribute set during AgentLoop.__init__. --- nanobot/command/builtin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 8ead6a131..94e46320b 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -65,8 +65,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: search_usage_text: str | None = None try: from nanobot.utils.searchusage import fetch_search_usage - web_cfg = getattr(getattr(loop, "config", None), "tools", None) - web_cfg = getattr(web_cfg, "web", None) if web_cfg else None + web_cfg = getattr(loop, "web_config", None) search_cfg = getattr(web_cfg, "search", None) if web_cfg else None if search_cfg is not None: provider = getattr(search_cfg, "provider", "duckduckgo") From e528e6dd96bdc8bef78e1286fc7a02f69621e178 Mon Sep 17 00:00:00 2001 From: yanghan-cyber Date: Mon, 6 Apr 2026 18:56:28 +0800 Subject: [PATCH 59/85] fix(status): parse actual Tavily API response structure The Tavily /usage endpoint returns a nested "account" object with plan_usage/plan_limit/search_usage/etc fields, not the flat structure with used/limit/breakdown that was assumed. This caused all usage values to be None. --- nanobot/utils/searchusage.py | 45 +++++++++++++++++------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/nanobot/utils/searchusage.py b/nanobot/utils/searchusage.py index 3e0c86101..ac490aadd 100644 --- a/nanobot/utils/searchusage.py +++ b/nanobot/utils/searchusage.py @@ -129,43 +129,40 @@ def _parse_tavily_usage(data: dict[str, Any]) -> SearchUsageInfo: """ Parse Tavily /usage response. - Expected shape (may vary by plan): + Actual API response shape: { - "used": 142, - "limit": 1000, - "remaining": 858, - "reset_date": "2026-05-01", - "breakdown": { - "search": 120, - "extract": 15, - "crawl": 7 + "account": { + "current_plan": "Researcher", + "plan_usage": 20, + "plan_limit": 1000, + "search_usage": 20, + "crawl_usage": 0, + "extract_usage": 0, + "map_usage": 0, + "research_usage": 0, + "paygo_usage": 0, + "paygo_limit": null } } """ - used = data.get("used") - limit = data.get("limit") - remaining = data.get("remaining") - reset_date = data.get("reset_date") or data.get("resetDate") + account = data.get("account") or {} + used = account.get("plan_usage") + limit = account.get("plan_limit") - # Compute remaining if not provided - if remaining is None and used is not None and limit is not None: + # Compute remaining + remaining = None + if used is not None and limit is not None: remaining = max(0, limit - used) - breakdown = data.get("breakdown") or {} - search_used = breakdown.get("search") - extract_used = breakdown.get("extract") - crawl_used = breakdown.get("crawl") - return SearchUsageInfo( provider="tavily", supported=True, used=used, limit=limit, remaining=remaining, - reset_date=str(reset_date) if reset_date else None, - search_used=search_used, - extract_used=extract_used, - crawl_used=crawl_used, + search_used=account.get("search_usage"), + extract_used=account.get("extract_usage"), + crawl_used=account.get("crawl_usage"), ) From dad9c07843036b4a4e93425fa29307db7112a95f Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:17:00 +0000 Subject: [PATCH 60/85] fix(tests): update Tavily usage tests to match actual API response shape The _parse_tavily_usage implementation was updated to use the real {account: {plan_usage, plan_limit, ...}} structure, but the tests still used the old flat {used, limit, breakdown} format. Made-with: Cursor --- tests/utils/test_searchusage.py | 47 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/tests/utils/test_searchusage.py b/tests/utils/test_searchusage.py index dd8c62571..205ccd917 100644 --- a/tests/utils/test_searchusage.py +++ b/tests/utils/test_searchusage.py @@ -79,11 +79,18 @@ class TestSearchUsageInfoFormat: class TestParseTavilyUsage: def test_full_response(self): data = { - "used": 142, - "limit": 1000, - "remaining": 858, - "reset_date": "2026-05-01", - "breakdown": {"search": 120, "extract": 15, "crawl": 7}, + "account": { + "current_plan": "Researcher", + "plan_usage": 142, + "plan_limit": 1000, + "search_usage": 120, + "extract_usage": 15, + "crawl_usage": 7, + "map_usage": 0, + "research_usage": 0, + "paygo_usage": 0, + "paygo_limit": None, + }, } info = _parse_tavily_usage(data) assert info.provider == "tavily" @@ -91,26 +98,20 @@ class TestParseTavilyUsage: assert info.used == 142 assert info.limit == 1000 assert info.remaining == 858 - assert info.reset_date == "2026-05-01" assert info.search_used == 120 assert info.extract_used == 15 assert info.crawl_used == 7 - def test_remaining_computed_when_missing(self): - data = {"used": 300, "limit": 1000} + def test_remaining_computed(self): + data = {"account": {"plan_usage": 300, "plan_limit": 1000}} info = _parse_tavily_usage(data) assert info.remaining == 700 def test_remaining_not_negative(self): - data = {"used": 1100, "limit": 1000} + data = {"account": {"plan_usage": 1100, "plan_limit": 1000}} info = _parse_tavily_usage(data) assert info.remaining == 0 - def test_camel_case_reset_date(self): - data = {"used": 10, "limit": 100, "resetDate": "2026-06-01"} - info = _parse_tavily_usage(data) - assert info.reset_date == "2026-06-01" - def test_empty_response(self): info = _parse_tavily_usage({}) assert info.provider == "tavily" @@ -118,8 +119,8 @@ class TestParseTavilyUsage: assert info.used is None assert info.limit is None - def test_no_breakdown_key(self): - data = {"used": 5, "limit": 50} + def test_no_breakdown_fields(self): + data = {"account": {"plan_usage": 5, "plan_limit": 50}} info = _parse_tavily_usage(data) assert info.search_used is None assert info.extract_used is None @@ -175,11 +176,14 @@ class TestFetchSearchUsageRouting: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "used": 142, - "limit": 1000, - "remaining": 858, - "reset_date": "2026-05-01", - "breakdown": {"search": 120, "extract": 15, "crawl": 7}, + "account": { + "current_plan": "Researcher", + "plan_usage": 142, + "plan_limit": 1000, + "search_usage": 120, + "extract_usage": 15, + "crawl_usage": 7, + }, } mock_response.raise_for_status = MagicMock() @@ -197,7 +201,6 @@ class TestFetchSearchUsageRouting: assert info.used == 142 assert info.limit == 1000 assert info.remaining == 858 - assert info.reset_date == "2026-05-01" assert info.search_used == 120 @pytest.mark.asyncio From 1243c0874528f2d5d5e20a91c2d73467e1ddbae5 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:22:20 +0000 Subject: [PATCH 61/85] docs: update news section --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 06218b1a4..6bd7ce8e8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ ## 📢 News +- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling. +- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish. - **2026-04-02** 🧱 **Long-running tasks** run more reliably — core runtime hardening. - **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix. - **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes. From 79234d237e840b144c73926a0cc1df6e810335d8 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:26:07 +0000 Subject: [PATCH 62/85] chore: bump version to 0.1.5 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index 11833c696..433dbe139 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,7 +2,7 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post6" +__version__ = "0.1.5" __logo__ = "🐈" from nanobot.nanobot import Nanobot, RunResult diff --git a/pyproject.toml b/pyproject.toml index ae87c7beb..a5807f962 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post6" +version = "0.1.5" description = "A lightweight personal AI assistant framework" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" From b719da7400fc5ec4d4499ffaee4b3c87f6d4848d Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:39:23 +0000 Subject: [PATCH 63/85] fix(feishu): use RawRequest for bot info API --- nanobot/channels/feishu.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7d75705a2..4798b818b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -408,12 +408,18 @@ class FeishuChannel(BaseChannel): def _fetch_bot_open_id(self) -> str | None: """Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" - from lark_oapi.api.bot.v3 import GetBotInfoRequest try: - request = GetBotInfoRequest.builder().build() - response = self._client.bot.v3.bot_info.get(request) - if response.success() and response.data and response.data.bot: - return getattr(response.data.bot, "open_id", None) + import lark_oapi as lark + request = lark.RawRequest.builder() \ + .http_method(lark.HttpMethod.GET) \ + .uri("/open-apis/bot/v3/info") \ + .build() + response = self._client.request(request) + if response.success(): + import json + data = json.loads(response.raw.content) + bot = (data.get("data") or data).get("bot") or data.get("bot") or {} + return bot.get("open_id") logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) return None except Exception as e: From bc2253c83f91c22e2023cca4f187b167fd3e66dc Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:45:08 +0000 Subject: [PATCH 64/85] docs: update v0.1.5 release news --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6bd7ce8e8..ac50d6999 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ ## 📢 News -- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling. +- **2026-04-06** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details. +- **2026-04-04** 🚀 GPT-5 model family support, Jinja2 response templates, Dream memory hardened, smarter retry handling. - **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish. -- **2026-04-02** 🧱 **Long-running tasks** run more reliably — core runtime hardening. +- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening. - **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix. - **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes. - **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks. From 6269876bc7d62d1522b904fa60c0d54f41771bbc Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:45:37 +0000 Subject: [PATCH 65/85] docs: update v0.1.5 release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac50d6999..ebb0caaa1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## 📢 News - **2026-04-06** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details. -- **2026-04-04** 🚀 GPT-5 model family support, Jinja2 response templates, Dream memory hardened, smarter retry handling. +- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling. - **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish. - **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening. - **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix. From a30e84bfd19657e168420b576f605d1b22c77ebd Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:46:16 +0000 Subject: [PATCH 66/85] docs: update v0.1.5 release news --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ebb0caaa1..68055583b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-04-06** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details. +- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details. - **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling. - **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish. - **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening. @@ -30,13 +30,14 @@ - **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API. - **2026-03-28** 📚 Provider docs refresh; skill template wording fix. - **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details. -- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries. -- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures. -- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured. +
Earlier news +- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries. +- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures. +- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured. - **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI. - **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command. - **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7). From 4dac0a8930a05d21e1df36a16e1f59e2c91b359e Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 11:55:47 +0000 Subject: [PATCH 67/85] docs: update nanobot docs badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 68055583b..0bf05625c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Downloads Python License + Docs Feishu WeChat Discord From bf459c7887881e223b97d72f3499b81b122f68ab Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 6 Apr 2026 13:15:40 +0000 Subject: [PATCH 68/85] fix(docker): fix volume mount path and add permission error guidance --- Dockerfile | 5 ++++- README.md | 11 ++++++----- entrypoint.sh | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 141a6f9b3..45fea1f6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,11 +37,14 @@ RUN useradd -m -u 1000 -s /bin/bash nanobot && \ mkdir -p /home/nanobot/.nanobot && \ chown -R nanobot:nanobot /home/nanobot /app +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + USER nanobot ENV HOME=/home/nanobot # Gateway default port EXPOSE 18790 -ENTRYPOINT ["nanobot"] +ENTRYPOINT ["entrypoint.sh"] CMD ["status"] diff --git a/README.md b/README.md index 0bf05625c..a2ea20f8c 100644 --- a/README.md +++ b/README.md @@ -1813,7 +1813,8 @@ print(resp.choices[0].message.content) ## 🐳 Docker > [!TIP] -> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. +> The `-v ~/.nanobot:/home/nanobot/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. +> The container runs as user `nanobot` (UID 1000). If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead. ### Docker Compose @@ -1836,17 +1837,17 @@ docker compose down # stop docker build -t nanobot . # Initialize config (first time only) -docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard +docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard # Edit config on host to add API keys vim ~/.nanobot/config.json # Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat) -docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway +docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway # Or run a single command -docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!" -docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status +docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!" +docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status ``` ## 🐧 Linux Service diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 000000000..ab780dc96 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +dir="$HOME/.nanobot" +if [ -d "$dir" ] && [ ! -w "$dir" ]; then + owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null) + cat >&2 < Date: Tue, 7 Apr 2026 11:24:29 +0800 Subject: [PATCH 69/85] fix(matrix): correct e2eeEnabled camelCase alias mapping The pydantic to_camel function generates 'e2EeEnabled' (treating 'ee' as a word boundary) for the field 'e2ee_enabled'. Users writing 'e2eeEnabled' in their config get the default value instead. Fix: add explicit alias='e2eeEnabled' to override the incorrect auto-generated alias. Both 'e2eeEnabled' and 'e2ee_enabled' now work. Fixes #2851 --- nanobot/channels/matrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 716a7f81a..a1435fcf3 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -209,7 +209,7 @@ class MatrixConfig(Base): password: str = "" access_token: str = "" device_id: str = "" - e2ee_enabled: bool = True + e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled") sync_stop_grace_seconds: int = 2 max_media_bytes: int = 20 * 1024 * 1024 allow_from: list[str] = Field(default_factory=list) From 44c799209556e1593f9f8a6646a41a6d78b6a835 Mon Sep 17 00:00:00 2001 From: Leo fu Date: Mon, 6 Apr 2026 12:12:34 -0400 Subject: [PATCH 70/85] fix(filesystem): correct write success message from bytes to characters len(content) counts Unicode code points, not UTF-8 bytes. For non-ASCII content such as Chinese or emoji, the reported count would be lower than the actual bytes written to disk, which is misleading to the agent. --- nanobot/agent/tools/filesystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 11f05c557..27ae3ccd9 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -186,7 +186,7 @@ class WriteFileTool(_FsTool): fp = self._resolve(path) fp.parent.mkdir(parents=True, exist_ok=True) fp.write_text(content, encoding="utf-8") - return f"Successfully wrote {len(content)} bytes to {fp}" + return f"Successfully wrote {len(content)} characters to {fp}" except PermissionError as e: return f"Error: {e}" except Exception as e: From f4904c4bdfec22667c7d96b5a6884cb44b21836b Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Mon, 6 Apr 2026 22:47:48 +0800 Subject: [PATCH 71/85] fix(cron): add optional name parameter to separate job label from message (#2680) --- nanobot/agent/tools/cron.py | 10 ++++++++-- tests/cron/test_cron_tool_list.py | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 064b6e4c9..f0d3ddab9 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -13,6 +13,10 @@ from nanobot.cron.types import CronJob, CronJobState, CronSchedule @tool_parameters( tool_parameters_schema( action=StringSchema("Action to perform", enum=["add", "list", "remove"]), + name=StringSchema( + "Optional short human-readable label for the job " + "(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message." + ), message=StringSchema( "Instruction for the agent to execute when the job triggers " "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')" @@ -93,6 +97,7 @@ class CronTool(Tool): async def execute( self, action: str, + name: str | None = None, message: str = "", every_seconds: int | None = None, cron_expr: str | None = None, @@ -105,7 +110,7 @@ class CronTool(Tool): if action == "add": if self._in_cron_context.get(): return "Error: cannot schedule new jobs from within a cron job execution" - return self._add_job(message, every_seconds, cron_expr, tz, at, deliver) + return self._add_job(name, message, every_seconds, cron_expr, tz, at, deliver) elif action == "list": return self._list_jobs() elif action == "remove": @@ -114,6 +119,7 @@ class CronTool(Tool): def _add_job( self, + name: str | None, message: str, every_seconds: int | None, cron_expr: str | None, @@ -158,7 +164,7 @@ class CronTool(Tool): return "Error: either every_seconds, cron_expr, or at is required" job = self._cron.add_job( - name=message[:30], + name=name or message[:30], schedule=schedule, message=message, deliver=deliver, diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 5da3f4891..e57ab26bd 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -299,7 +299,7 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None: tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") tool.set_context("telegram", "chat-1") - result = tool._add_job("Morning standup", None, "0 8 * * *", None, None) + result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None) assert result.startswith("Created job") job = tool._cron.list_jobs()[0] @@ -310,7 +310,7 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None: tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") tool.set_context("telegram", "chat-1") - result = tool._add_job("Morning reminder", None, None, None, "2026-03-25T08:00:00") + result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00") assert result.startswith("Created job") job = tool._cron.list_jobs()[0] @@ -322,7 +322,7 @@ def test_add_job_delivers_by_default(tmp_path) -> None: tool = _make_tool(tmp_path) tool.set_context("telegram", "chat-1") - result = tool._add_job("Morning standup", 60, None, None, None) + result = tool._add_job(None, "Morning standup", 60, None, None, None) assert result.startswith("Created job") job = tool._cron.list_jobs()[0] @@ -333,7 +333,7 @@ def test_add_job_can_disable_delivery(tmp_path) -> None: tool = _make_tool(tmp_path) tool.set_context("telegram", "chat-1") - result = tool._add_job("Background refresh", 60, None, None, None, deliver=False) + result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False) assert result.startswith("Created job") job = tool._cron.list_jobs()[0] From 5ee96721f7c49673a80d8f5c75ba6df85f1068cf Mon Sep 17 00:00:00 2001 From: Jiajun Xie Date: Mon, 6 Apr 2026 19:35:06 +0800 Subject: [PATCH 72/85] ci: add ruff lint check for unused imports and variables Add CI step to detect unused imports (F401) and unused variables (F841) with ruff. Clean up existing violations: - Remove unused Consolidator import in agent/__init__.py - Remove unused re import in agent/loop.py - Remove unused Path import in channels/feishu.py - Remove unused ContentRepositoryConfigError import in channels/matrix.py - Remove unused field and CommandHandler imports in channels/telegram.py - Remove unused exception variable in channels/weixin.py --- .github/workflows/ci.yml | 3 +++ nanobot/agent/__init__.py | 2 +- nanobot/agent/loop.py | 1 - nanobot/channels/feishu.py | 1 - nanobot/channels/matrix.py | 1 - nanobot/channels/telegram.py | 4 ++-- nanobot/channels/weixin.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00362d02..fac9be66c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,8 @@ jobs: - name: Install all dependencies run: uv sync --all-extras + - name: Lint with ruff + run: uv run ruff check nanobot --select F401,F841 + - name: Run tests run: uv run pytest tests/ diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py index a8805a3ad..9eef5a0c6 100644 --- a/nanobot/agent/__init__.py +++ b/nanobot/agent/__init__.py @@ -3,7 +3,7 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook from nanobot.agent.loop import AgentLoop -from nanobot.agent.memory import Consolidator, Dream, MemoryStore +from nanobot.agent.memory import Dream, MemoryStore from nanobot.agent.skills import SkillsLoader from nanobot.agent.subagent import SubagentManager diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 93dcaabec..9e5ca0bc9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import json -import re import os import time from contextlib import AsyncExitStack, nullcontext diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4798b818b..c6124d0c9 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -9,7 +9,6 @@ import time import uuid from collections import OrderedDict from dataclasses import dataclass -from pathlib import Path from typing import Any, Literal from loguru import logger diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a1435fcf3..85a167a3a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -18,7 +18,6 @@ try: from nio import ( AsyncClient, AsyncClientConfig, - ContentRepositoryConfigError, DownloadError, InviteEvent, JoinError, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 07ea7cb05..aeb36d8e4 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -6,14 +6,14 @@ import asyncio import re import time import unicodedata -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Literal from loguru import logger from pydantic import Field from telegram import BotCommand, ReactionTypeEmoji, ReplyParameters, Update from telegram.error import BadRequest, NetworkError, TimedOut -from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters +from telegram.ext import Application, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 2266bc9f0..3f87e2203 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -484,7 +484,7 @@ class WeixinChannel(BaseChannel): except httpx.TimeoutException: # Normal for long-poll, just retry continue - except Exception as e: + except Exception: if not self._running: break consecutive_failures += 1 From 67e6f8cc7a68806a394933714c50edf99e9961ff Mon Sep 17 00:00:00 2001 From: flobo3 Date: Tue, 7 Apr 2026 07:37:01 +0300 Subject: [PATCH 73/85] fix(docker): strip Windows CRLF from entrypoint.sh --- .gitattributes | 2 ++ Dockerfile | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9da244d8c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Ensure shell scripts always use LF line endings (Docker/Linux compat) +*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile index 45fea1f6f..3b86d61b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ RUN useradd -m -u 1000 -s /bin/bash nanobot && \ chown -R nanobot:nanobot /home/nanobot /app COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh USER nanobot ENV HOME=/home/nanobot From 64bd7234b349949a27cdab08fd991144cb0519ed Mon Sep 17 00:00:00 2001 From: bahtya Date: Tue, 7 Apr 2026 11:26:18 +0800 Subject: [PATCH 74/85] fix(cli): sanitize surrogate characters in prompt history to prevent UnicodeEncodeError On Windows, certain Unicode input (emoji, mixed-script text, surrogate pairs) causes prompt_toolkit's FileHistory to crash with UnicodeEncodeError when writing the history file. Fix: wrap FileHistory with a _SafeFileHistory subclass that sanitizes surrogate characters before writing, replacing invalid sequences instead of crashing. Fixes #2846 --- nanobot/cli/commands.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 29eb19c31..2e18045f4 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -118,8 +118,17 @@ def _init_prompt_session() -> None: history_file = get_cli_history_path() history_file.parent.mkdir(parents=True, exist_ok=True) + # Wrap FileHistory to sanitize surrogate characters on write. + # Without this, special Unicode input (emoji, mixed-script) crashes + # prompt_toolkit's history file write on Windows with UnicodeEncodeError. + # See issue #2846. + class _SafeFileHistory(FileHistory): + def store_string(self, string: str) -> None: + safe = string.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + super().store_string(safe) + _PROMPT_SESSION = PromptSession( - history=FileHistory(str(history_file)), + history=_SafeFileHistory(str(history_file)), enable_open_in_editor=False, multiline=False, # Enter submits (single line mode) ) From 075bdd5c3cd523012c7a4b63cb080a9ca9d938ba Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 7 Apr 2026 05:56:52 +0000 Subject: [PATCH 75/85] refactor: move SafeFileHistory to module level + add regression tests - Promote _SafeFileHistory to module-level SafeFileHistory for testability - Add 5 regression tests: surrogates, normal text, emoji, mixed CJK, multi-surrogates Made-with: Cursor --- nanobot/cli/commands.py | 24 +++++++++------- tests/cli/test_safe_file_history.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 tests/cli/test_safe_file_history.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2e18045f4..a1fb7c0e0 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -33,6 +33,19 @@ from rich.table import Table from rich.text import Text from nanobot import __logo__, __version__ + + +class SafeFileHistory(FileHistory): + """FileHistory subclass that sanitizes surrogate characters on write. + + On Windows, special Unicode input (emoji, mixed-script) can produce + surrogate characters that crash prompt_toolkit's file write. + See issue #2846. + """ + + def store_string(self, string: str) -> None: + safe = string.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + super().store_string(safe) from nanobot.cli.stream import StreamRenderer, ThinkingSpinner from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.schema import Config @@ -118,17 +131,8 @@ def _init_prompt_session() -> None: history_file = get_cli_history_path() history_file.parent.mkdir(parents=True, exist_ok=True) - # Wrap FileHistory to sanitize surrogate characters on write. - # Without this, special Unicode input (emoji, mixed-script) crashes - # prompt_toolkit's history file write on Windows with UnicodeEncodeError. - # See issue #2846. - class _SafeFileHistory(FileHistory): - def store_string(self, string: str) -> None: - safe = string.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") - super().store_string(safe) - _PROMPT_SESSION = PromptSession( - history=_SafeFileHistory(str(history_file)), + history=SafeFileHistory(str(history_file)), enable_open_in_editor=False, multiline=False, # Enter submits (single line mode) ) diff --git a/tests/cli/test_safe_file_history.py b/tests/cli/test_safe_file_history.py new file mode 100644 index 000000000..78b5e2339 --- /dev/null +++ b/tests/cli/test_safe_file_history.py @@ -0,0 +1,44 @@ +"""Regression tests for SafeFileHistory (issue #2846). + +Surrogate characters in CLI input must not crash history file writes. +""" + +from nanobot.cli.commands import SafeFileHistory + + +class TestSafeFileHistory: + def test_surrogate_replaced(self, tmp_path): + """Surrogate pairs are replaced with U+FFFD, not crash.""" + hist = SafeFileHistory(str(tmp_path / "history")) + hist.store_string("hello \udce9 world") + entries = list(hist.load_history_strings()) + assert len(entries) == 1 + assert "\udce9" not in entries[0] + assert "hello" in entries[0] + assert "world" in entries[0] + + def test_normal_text_unchanged(self, tmp_path): + hist = SafeFileHistory(str(tmp_path / "history")) + hist.store_string("normal ascii text") + entries = list(hist.load_history_strings()) + assert entries[0] == "normal ascii text" + + def test_emoji_preserved(self, tmp_path): + hist = SafeFileHistory(str(tmp_path / "history")) + hist.store_string("hello 🐈 nanobot") + entries = list(hist.load_history_strings()) + assert entries[0] == "hello 🐈 nanobot" + + def test_mixed_unicode_preserved(self, tmp_path): + """CJK + emoji + latin should all pass through cleanly.""" + hist = SafeFileHistory(str(tmp_path / "history")) + hist.store_string("你好 hello こんにちは 🎉") + entries = list(hist.load_history_strings()) + assert entries[0] == "你好 hello こんにちは 🎉" + + def test_multiple_surrogates(self, tmp_path): + hist = SafeFileHistory(str(tmp_path / "history")) + hist.store_string("\udce9\udcf1\udcff") + entries = list(hist.load_history_strings()) + assert len(entries) == 1 + assert "\udce9" not in entries[0] From 0291d1f716bf0205914937f9973aa89d4faac4ee Mon Sep 17 00:00:00 2001 From: wudongxue Date: Mon, 30 Mar 2026 18:11:01 +0800 Subject: [PATCH 76/85] feat: resolve mentions data --- nanobot/channels/feishu.py | 477 ++++++++++++++++++++++++++----------- 1 file changed, 337 insertions(+), 140 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index c6124d0c9..60e59a3ba 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -11,6 +11,8 @@ from collections import OrderedDict from dataclasses import dataclass from typing import Any, Literal +from lark_oapi.api.im.v1.model import P2ImMessageReceiveV1, MentionEvent + from loguru import logger from nanobot.bus.events import OutboundMessage @@ -75,7 +77,9 @@ def _extract_interactive_content(content: dict) -> list[str]: elif isinstance(title, str): parts.append(f"title: {title}") - for elements in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + for elements in ( + content.get("elements", []) if isinstance(content.get("elements"), list) else [] + ): for element in elements: parts.extend(_extract_element_content(element)) @@ -259,6 +263,7 @@ _STREAM_ELEMENT_ID = "streaming_md" @dataclass class _FeishuStreamBuf: """Per-chat streaming accumulator using CardKit streaming API.""" + text: str = "" card_id: str | None = None sequence: int = 0 @@ -316,21 +321,22 @@ class FeishuChannel(BaseChannel): return import lark_oapi as lark + self._running = True self._loop = asyncio.get_running_loop() # Create Lark client for sending messages - self._client = lark.Client.builder() \ - .app_id(self.config.app_id) \ - .app_secret(self.config.app_secret) \ - .log_level(lark.LogLevel.INFO) \ + self._client = ( + lark.Client.builder() + .app_id(self.config.app_id) + .app_secret(self.config.app_secret) + .log_level(lark.LogLevel.INFO) .build() + ) builder = lark.EventDispatcherHandler.builder( self.config.encrypt_key or "", self.config.verification_token or "", - ).register_p2_im_message_receive_v1( - self._on_message_sync - ) + ).register_p2_im_message_receive_v1(self._on_message_sync) builder = self._register_optional_event( builder, "register_p2_im_message_reaction_created_v1", self._on_reaction_created ) @@ -349,7 +355,7 @@ class FeishuChannel(BaseChannel): self.config.app_id, self.config.app_secret, event_handler=event_handler, - log_level=lark.LogLevel.INFO + log_level=lark.LogLevel.INFO, ) # Start WebSocket client in a separate thread with reconnect loop. @@ -360,6 +366,7 @@ class FeishuChannel(BaseChannel): def run_ws(): import time import lark_oapi.ws.client as _lark_ws_client + ws_loop = asyncio.new_event_loop() asyncio.set_event_loop(ws_loop) # Patch the module-level loop used by lark's ws Client.start() @@ -409,13 +416,17 @@ class FeishuChannel(BaseChannel): """Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" try: import lark_oapi as lark - request = lark.RawRequest.builder() \ - .http_method(lark.HttpMethod.GET) \ - .uri("/open-apis/bot/v3/info") \ + + request = ( + lark.RawRequest.builder() + .http_method(lark.HttpMethod.GET) + .uri("/open-apis/bot/v3/info") .build() + ) response = self._client.request(request) if response.success(): import json + data = json.loads(response.raw.content) bot = (data.get("data") or data).get("bot") or data.get("bot") or {} return bot.get("open_id") @@ -425,6 +436,45 @@ class FeishuChannel(BaseChannel): logger.warning("Error fetching bot info: {}", e) return None + @staticmethod + def _resolve_mentions(text: str, mentions: list[MentionEvent] | None) -> str: + """Replace @_user_n placeholders with actual user info from mentions. + + Args: + text: The message text containing @_user_n placeholders + mentions: List of mention objects from Feishu message + + Returns: + Text with placeholders replaced by @姓名 (open_id) + """ + if not mentions or not text: + return text + + for mention in mentions: + key = mention.key or None + if not key or key not in text: + continue + + userID = mention.id or None + if not userID: + continue + + open_id = userID.open_id + user_id = userID.user_id + name = mention.name or key + + # Format: @姓名 (open_id, user_id: xxx) + if open_id and user_id: + replacement = f"@{name} ({open_id}, user id: {user_id})" + elif open_id: + replacement = f"@{name} ({open_id})" + else: + replacement = f"@{name}" + + text = text.replace(key, replacement) + + return text + def _is_bot_mentioned(self, message: Any) -> bool: """Check if the bot is @mentioned in the message.""" raw_content = message.content or "" @@ -453,20 +503,30 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> str | None: """Sync helper for adding reaction (runs in thread pool).""" - from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji + from lark_oapi.api.im.v1 import ( + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + Emoji, + ) + try: - request = CreateMessageReactionRequest.builder() \ - .message_id(message_id) \ + request = ( + CreateMessageReactionRequest.builder() + .message_id(message_id) .request_body( CreateMessageReactionRequestBody.builder() .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) .build() - ).build() + ) + .build() + ) response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) + logger.warning( + "Failed to add reaction: code={}, msg={}", response.code, response.msg + ) return None else: logger.debug("Added {} reaction to message {}", emoji_type, message_id) @@ -490,17 +550,22 @@ class FeishuChannel(BaseChannel): def _remove_reaction_sync(self, message_id: str, reaction_id: str) -> None: """Sync helper for removing reaction (runs in thread pool).""" from lark_oapi.api.im.v1 import DeleteMessageReactionRequest + try: - request = DeleteMessageReactionRequest.builder() \ - .message_id(message_id) \ - .reaction_id(reaction_id) \ + request = ( + DeleteMessageReactionRequest.builder() + .message_id(message_id) + .reaction_id(reaction_id) .build() + ) response = self._client.im.v1.message_reaction.delete(request) if response.success(): logger.debug("Removed reaction {} from message {}", reaction_id, message_id) else: - logger.debug("Failed to remove reaction: code={}, msg={}", response.code, response.msg) + logger.debug( + "Failed to remove reaction: code={}, msg={}", response.code, response.msg + ) except Exception as e: logger.debug("Error removing reaction: {}", e) @@ -555,27 +620,35 @@ class FeishuChannel(BaseChannel): lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] if len(lines) < 3: return None + def split(_line: str) -> list[str]: return [c.strip() for c in _line.strip("|").split("|")] + headers = [cls._strip_md_formatting(h) for h in split(lines[0])] rows = [[cls._strip_md_formatting(c) for c in 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)] + columns = [ + {"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} + for i, h in enumerate(headers) + ] return { "tag": "table", "page_size": len(rows) + 1, "columns": columns, - "rows": [{f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows], + "rows": [ + {f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows + ], } def _build_card_elements(self, content: str) -> list[dict]: """Split content into div/markdown + table elements for Feishu card.""" elements, last_end = [], 0 for m in self._TABLE_RE.finditer(content): - before = content[last_end:m.start()] + before = content[last_end : m.start()] if before.strip(): elements.extend(self._split_headings(before)) - elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) + elements.append( + self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)} + ) last_end = m.end() remaining = content[last_end:] if remaining.strip(): @@ -583,7 +656,9 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] @staticmethod - def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]: + def _split_elements_by_table_limit( + elements: list[dict], max_tables: int = 1 + ) -> list[list[dict]]: """Split card elements into groups with at most *max_tables* table elements each. Feishu cards have a hard limit of one table per card (API error 11310). @@ -616,23 +691,25 @@ class FeishuChannel(BaseChannel): code_blocks = [] for m in self._CODE_BLOCK_RE.finditer(content): code_blocks.append(m.group(1)) - protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1) + protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1) elements = [] last_end = 0 for m in self._HEADING_RE.finditer(protected): - before = protected[last_end:m.start()].strip() + before = protected[last_end : m.start()].strip() if before: elements.append({"tag": "markdown", "content": before}) text = self._strip_md_formatting(m.group(2).strip()) display_text = f"**{text}**" if text else "" - elements.append({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": display_text, - }, - }) + elements.append( + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": display_text, + }, + } + ) last_end = m.end() remaining = protected[last_end:].strip() if remaining: @@ -648,19 +725,19 @@ class FeishuChannel(BaseChannel): # ── Smart format detection ────────────────────────────────────────── # Patterns that indicate "complex" markdown needing card rendering _COMPLEX_MD_RE = re.compile( - r"```" # fenced code block + r"```" # fenced code block r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator) - r"|^#{1,6}\s+" # headings - , re.MULTILINE, + r"|^#{1,6}\s+", # headings + re.MULTILINE, ) # Simple markdown patterns (bold, italic, strikethrough) _SIMPLE_MD_RE = re.compile( - r"\*\*.+?\*\*" # **bold** - r"|__.+?__" # __bold__ + r"\*\*.+?\*\*" # **bold** + r"|__.+?__" # __bold__ r"|(? str | None: """Upload an image to Feishu and return the image_key.""" from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody + try: with open(file_path, "rb") as f: - request = CreateImageRequest.builder() \ + request = ( + CreateImageRequest.builder() .request_body( - CreateImageRequestBody.builder() - .image_type("message") - .image(f) - .build() - ).build() + CreateImageRequestBody.builder().image_type("message").image(f).build() + ) + .build() + ) response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: - logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) + logger.error( + "Failed to upload image: code={}, msg={}", response.code, response.msg + ) return None except Exception as e: logger.error("Error uploading image {}: {}", file_path, e) @@ -795,49 +884,62 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody + ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) try: with open(file_path, "rb") as f: - request = CreateFileRequest.builder() \ + request = ( + CreateFileRequest.builder() .request_body( CreateFileRequestBody.builder() .file_type(file_type) .file_name(file_name) .file(f) .build() - ).build() + ) + .build() + ) response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: - logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) + logger.error( + "Failed to upload file: code={}, msg={}", response.code, response.msg + ) return None except Exception as e: logger.error("Error uploading file {}: {}", file_path, e) return None - def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: + def _download_image_sync( + self, message_id: str, image_key: str + ) -> tuple[bytes | None, str | None]: """Download an image from Feishu message by message_id and image_key.""" from lark_oapi.api.im.v1 import GetMessageResourceRequest + try: - request = GetMessageResourceRequest.builder() \ - .message_id(message_id) \ - .file_key(image_key) \ - .type("image") \ + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(image_key) + .type("image") .build() + ) response = self._client.im.v1.message_resource.get(request) if response.success(): file_data = response.file # GetMessageResourceRequest returns BytesIO, need to read bytes - if hasattr(file_data, 'read'): + if hasattr(file_data, "read"): file_data = file_data.read() return file_data, response.file_name else: - logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) + logger.error( + "Failed to download image: code={}, msg={}", response.code, response.msg + ) return None, None except Exception as e: logger.error("Error downloading image {}: {}", image_key, e) @@ -869,17 +971,19 @@ class FeishuChannel(BaseChannel): file_data = file_data.read() return file_data, response.file_name else: - logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg) + logger.error( + "Failed to download {}: code={}, msg={}", + resource_type, + response.code, + response.msg, + ) return None, None except Exception: logger.exception("Error downloading {} {}", resource_type, file_key) return None, None async def _download_and_save_media( - self, - msg_type: str, - content_json: dict, - message_id: str | None = None + self, msg_type: str, content_json: dict, message_id: str | None = None ) -> tuple[str | None, str]: """ Download media from Feishu and save to local disk. @@ -928,13 +1032,16 @@ class FeishuChannel(BaseChannel): Returns a "[Reply to: ...]" context string, or None on failure. """ from lark_oapi.api.im.v1 import GetMessageRequest + try: request = GetMessageRequest.builder().message_id(message_id).build() response = self._client.im.v1.message.get(request) if not response.success(): logger.debug( "Feishu: could not fetch parent message {}: code={}, msg={}", - message_id, response.code, response.msg, + message_id, + response.code, + response.msg, ) return None items = getattr(response.data, "items", None) @@ -969,20 +1076,24 @@ class FeishuChannel(BaseChannel): def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool: """Reply to an existing Feishu message using the Reply API (synchronous).""" from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody + try: - request = ReplyMessageRequest.builder() \ - .message_id(parent_message_id) \ + request = ( + ReplyMessageRequest.builder() + .message_id(parent_message_id) .request_body( - ReplyMessageRequestBody.builder() - .msg_type(msg_type) - .content(content) - .build() - ).build() + ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build() + ) + .build() + ) response = self._client.im.v1.message.reply(request) if not response.success(): logger.error( "Failed to reply to Feishu message {}: code={}, msg={}, log_id={}", - parent_message_id, response.code, response.msg, response.get_log_id() + parent_message_id, + response.code, + response.msg, + response.get_log_id(), ) return False logger.debug("Feishu reply sent to message {}", parent_message_id) @@ -991,24 +1102,33 @@ class FeishuChannel(BaseChannel): logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) return False - def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> str | None: + def _send_message_sync( + self, receive_id_type: str, receive_id: str, msg_type: str, content: str + ) -> str | None: """Send a single message and return the message_id on success.""" from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody + try: - request = CreateMessageRequest.builder() \ - .receive_id_type(receive_id_type) \ + request = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) .request_body( CreateMessageRequestBody.builder() .receive_id(receive_id) .msg_type(msg_type) .content(content) .build() - ).build() + ) + .build() + ) response = self._client.im.v1.message.create(request) if not response.success(): logger.error( "Failed to send Feishu {} message: code={}, msg={}, log_id={}", - msg_type, response.code, response.msg, response.get_log_id() + msg_type, + response.code, + response.msg, + response.get_log_id(), ) return None msg_id = getattr(response.data, "message_id", None) @@ -1021,31 +1141,44 @@ class FeishuChannel(BaseChannel): def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None: """Create a CardKit streaming card, send it to chat, return card_id.""" from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody + card_json = { "schema": "2.0", "config": {"wide_screen_mode": True, "update_multi": True, "streaming_mode": True}, - "body": {"elements": [{"tag": "markdown", "content": "", "element_id": _STREAM_ELEMENT_ID}]}, + "body": { + "elements": [{"tag": "markdown", "content": "", "element_id": _STREAM_ELEMENT_ID}] + }, } try: - request = CreateCardRequest.builder().request_body( - CreateCardRequestBody.builder() - .type("card_json") - .data(json.dumps(card_json, ensure_ascii=False)) + request = ( + CreateCardRequest.builder() + .request_body( + CreateCardRequestBody.builder() + .type("card_json") + .data(json.dumps(card_json, ensure_ascii=False)) + .build() + ) .build() - ).build() + ) response = self._client.cardkit.v1.card.create(request) if not response.success(): - logger.warning("Failed to create streaming card: code={}, msg={}", response.code, response.msg) + logger.warning( + "Failed to create streaming card: code={}, msg={}", response.code, response.msg + ) return None card_id = getattr(response.data, "card_id", None) if card_id: message_id = self._send_message_sync( - receive_id_type, chat_id, "interactive", + receive_id_type, + chat_id, + "interactive", json.dumps({"type": "card", "data": {"card_id": card_id}}), ) if message_id: return card_id - logger.warning("Created streaming card {} but failed to send it to {}", card_id, chat_id) + logger.warning( + "Created streaming card {} but failed to send it to {}", card_id, chat_id + ) return None except Exception as e: logger.warning("Error creating streaming card: {}", e) @@ -1053,18 +1186,32 @@ class FeishuChannel(BaseChannel): def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool: """Stream-update the markdown element on a CardKit card (typewriter effect).""" - from lark_oapi.api.cardkit.v1 import ContentCardElementRequest, ContentCardElementRequestBody + from lark_oapi.api.cardkit.v1 import ( + ContentCardElementRequest, + ContentCardElementRequestBody, + ) + try: - request = ContentCardElementRequest.builder() \ - .card_id(card_id) \ - .element_id(_STREAM_ELEMENT_ID) \ + request = ( + ContentCardElementRequest.builder() + .card_id(card_id) + .element_id(_STREAM_ELEMENT_ID) .request_body( ContentCardElementRequestBody.builder() - .content(content).sequence(sequence).build() - ).build() + .content(content) + .sequence(sequence) + .build() + ) + .build() + ) response = self._client.cardkit.v1.card_element.content(request) if not response.success(): - logger.warning("Failed to stream-update card {}: code={}, msg={}", card_id, response.code, response.msg) + logger.warning( + "Failed to stream-update card {}: code={}, msg={}", + card_id, + response.code, + response.msg, + ) return False return True except Exception as e: @@ -1079,22 +1226,28 @@ class FeishuChannel(BaseChannel): Sequence must strictly exceed the previous card OpenAPI operation on this entity. """ from lark_oapi.api.cardkit.v1 import SettingsCardRequest, SettingsCardRequestBody + settings_payload = json.dumps({"config": {"streaming_mode": False}}, ensure_ascii=False) try: - request = SettingsCardRequest.builder() \ - .card_id(card_id) \ + request = ( + SettingsCardRequest.builder() + .card_id(card_id) .request_body( SettingsCardRequestBody.builder() .settings(settings_payload) .sequence(sequence) .uuid(str(uuid.uuid4())) .build() - ).build() + ) + .build() + ) response = self._client.cardkit.v1.card.settings(request) if not response.success(): logger.warning( "Failed to close streaming on card {}: code={}, msg={}", - card_id, response.code, response.msg, + card_id, + response.code, + response.msg, ) return False return True @@ -1102,7 +1255,9 @@ class FeishuChannel(BaseChannel): logger.warning("Error closing streaming on card {}: {}", card_id, e) return False - async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: + async def send_delta( + self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None + ) -> None: """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.""" if not self._client: return @@ -1121,17 +1276,31 @@ class FeishuChannel(BaseChannel): if buf.card_id: buf.sequence += 1 await loop.run_in_executor( - None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, + None, + self._stream_update_text_sync, + buf.card_id, + buf.text, + buf.sequence, ) # Required so the chat list preview exits the streaming placeholder (Feishu streaming card docs). buf.sequence += 1 await loop.run_in_executor( - None, self._close_streaming_mode_sync, buf.card_id, buf.sequence, + None, + self._close_streaming_mode_sync, + buf.card_id, + buf.sequence, ) else: - for chunk in self._split_elements_by_table_limit(self._build_card_elements(buf.text)): - card = json.dumps({"config": {"wide_screen_mode": True}, "elements": chunk}, ensure_ascii=False) - await loop.run_in_executor(None, self._send_message_sync, rid_type, chat_id, "interactive", card) + for chunk in self._split_elements_by_table_limit( + self._build_card_elements(buf.text) + ): + card = json.dumps( + {"config": {"wide_screen_mode": True}, "elements": chunk}, + ensure_ascii=False, + ) + await loop.run_in_executor( + None, self._send_message_sync, rid_type, chat_id, "interactive", card + ) return # --- accumulate delta --- @@ -1145,15 +1314,21 @@ class FeishuChannel(BaseChannel): now = time.monotonic() if buf.card_id is None: - card_id = await loop.run_in_executor(None, self._create_streaming_card_sync, rid_type, chat_id) + card_id = await loop.run_in_executor( + None, self._create_streaming_card_sync, rid_type, chat_id + ) if card_id: buf.card_id = card_id buf.sequence = 1 - await loop.run_in_executor(None, self._stream_update_text_sync, card_id, buf.text, 1) + await loop.run_in_executor( + None, self._stream_update_text_sync, card_id, buf.text, 1 + ) buf.last_edit = now elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL: buf.sequence += 1 - await loop.run_in_executor(None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence) + await loop.run_in_executor( + None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence + ) buf.last_edit = now async def send(self, msg: OutboundMessage) -> None: @@ -1179,14 +1354,13 @@ class FeishuChannel(BaseChannel): # Only the very first send (media or text) in this call uses reply; subsequent # chunks/media fall back to plain create to avoid redundant quote bubbles. reply_message_id: str | None = None - if ( - self.config.reply_to_message - and not msg.metadata.get("_progress", False) - ): + if self.config.reply_to_message and not msg.metadata.get("_progress", False): reply_message_id = msg.metadata.get("message_id") or None # For topic group messages, always reply to keep context in thread elif msg.metadata.get("thread_id"): - reply_message_id = msg.metadata.get("root_id") or msg.metadata.get("message_id") or None + reply_message_id = ( + msg.metadata.get("root_id") or msg.metadata.get("message_id") or None + ) first_send = True # tracks whether the reply has already been used @@ -1210,8 +1384,10 @@ class FeishuChannel(BaseChannel): key = await loop.run_in_executor(None, self._upload_image_sync, file_path) if key: await loop.run_in_executor( - None, _do_send, - "image", json.dumps({"image_key": key}, ensure_ascii=False), + None, + _do_send, + "image", + json.dumps({"image_key": key}, ensure_ascii=False), ) else: key = await loop.run_in_executor(None, self._upload_file_sync, file_path) @@ -1226,8 +1402,10 @@ class FeishuChannel(BaseChannel): else: media_type = "file" await loop.run_in_executor( - None, _do_send, - media_type, json.dumps({"file_key": key}, ensure_ascii=False), + None, + _do_send, + media_type, + json.dumps({"file_key": key}, ensure_ascii=False), ) if msg.content and msg.content.strip(): @@ -1249,8 +1427,10 @@ class FeishuChannel(BaseChannel): for chunk in self._split_elements_by_table_limit(elements): card = {"config": {"wide_screen_mode": True}, "elements": chunk} await loop.run_in_executor( - None, _do_send, - "interactive", json.dumps(card, ensure_ascii=False), + None, + _do_send, + "interactive", + json.dumps(card, ensure_ascii=False), ) except Exception as e: @@ -1265,13 +1445,16 @@ class FeishuChannel(BaseChannel): if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) - async def _on_message(self, data: Any) -> None: + async def _on_message(self, data: P2ImMessageReceiveV1) -> None: """Handle incoming message from Feishu.""" try: event = data.event message = event.message sender = event.sender - + + logger.debug("Feishu raw message: {}", message.content) + logger.debug("Feishu mentions: {}", getattr(message, "mentions", None)) + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: @@ -1310,6 +1493,8 @@ class FeishuChannel(BaseChannel): if msg_type == "text": text = content_json.get("text", "") if text: + mentions = getattr(message, "mentions", None) + text = self._resolve_mentions(text, mentions) content_parts.append(text) elif msg_type == "post": @@ -1326,7 +1511,9 @@ class FeishuChannel(BaseChannel): content_parts.append(content_text) elif msg_type in ("image", "audio", "file", "media"): - file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) + file_path, content_text = await self._download_and_save_media( + msg_type, content_json, message_id + ) if file_path: media_paths.append(file_path) @@ -1337,7 +1524,14 @@ class FeishuChannel(BaseChannel): content_parts.append(content_text) - elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + elif msg_type in ( + "share_chat", + "share_user", + "interactive", + "share_calendar_event", + "system", + "merge_forward", + ): # Handle share cards and interactive messages text = _extract_share_card_content(content_json, msg_type) if text: @@ -1380,8 +1574,9 @@ class FeishuChannel(BaseChannel): "parent_id": parent_id, "root_id": root_id, "thread_id": thread_id, - } + }, ) + await self._del_reaction(message_id, reaction_id) except Exception as e: logger.error("Error processing Feishu message: {}", e) @@ -1445,7 +1640,9 @@ class FeishuChannel(BaseChannel): return "\n".join(part for part in parts if part) - async def _send_tool_hint_card(self, receive_id_type: str, receive_id: str, tool_hint: str) -> None: + async def _send_tool_hint_card( + self, receive_id_type: str, receive_id: str, tool_hint: str + ) -> None: """Send tool hint as an interactive card with formatted code block. Args: @@ -1461,15 +1658,15 @@ class FeishuChannel(BaseChannel): card = { "config": {"wide_screen_mode": True}, "elements": [ - { - "tag": "markdown", - "content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```" - } - ] + {"tag": "markdown", "content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```"} + ], } await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, receive_id, "interactive", + None, + self._send_message_sync, + receive_id_type, + receive_id, + "interactive", json.dumps(card, ensure_ascii=False), ) From b3294f79aa3fe8de36bc4802297211aca528b44f Mon Sep 17 00:00:00 2001 From: wudongxue Date: Tue, 31 Mar 2026 12:52:32 +0800 Subject: [PATCH 77/85] fix(feishu): ensure access token is initialized before fetching bot open_id The lark-oapi client requires token types to be explicitly configured so that the SDK can obtain and attach the tenant_access_token to raw requests. Without this, `_fetch_bot_open_id()` would fail with "Missing access token for authorization" because the token had not been provisioned at the time of the call. --- nanobot/channels/feishu.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 60e59a3ba..bac14cb84 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1,6 +1,7 @@ """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" import asyncio +import importlib.util import json import os import re @@ -11,18 +12,15 @@ from collections import OrderedDict from dataclasses import dataclass from typing import Any, Literal -from lark_oapi.api.im.v1.model import P2ImMessageReceiveV1, MentionEvent - +from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 from loguru import logger +from pydantic import Field from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base -from pydantic import Field - -import importlib.util FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None @@ -292,11 +290,13 @@ class FeishuChannel(BaseChannel): return FeishuConfig().model_dump(by_alias=True) def __init__(self, config: Any, bus: MessageBus): + import lark_oapi as lark + if isinstance(config, dict): config = FeishuConfig.model_validate(config) super().__init__(config, bus) self.config: FeishuConfig = config - self._client: Any = None + self._client: lark.Client = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache @@ -340,6 +340,9 @@ class FeishuChannel(BaseChannel): builder = self._register_optional_event( builder, "register_p2_im_message_reaction_created_v1", self._on_reaction_created ) + builder = self._register_optional_event( + builder, "register_p2_im_message_reaction_deleted_v1", self._on_reaction_deleted + ) builder = self._register_optional_event( builder, "register_p2_im_message_message_read_v1", self._on_message_read ) @@ -365,6 +368,7 @@ class FeishuChannel(BaseChannel): # "This event loop is already running" errors. def run_ws(): import time + import lark_oapi.ws.client as _lark_ws_client ws_loop = asyncio.new_event_loop() @@ -418,9 +422,10 @@ class FeishuChannel(BaseChannel): import lark_oapi as lark request = ( - lark.RawRequest.builder() + lark.BaseRequest.builder() .http_method(lark.HttpMethod.GET) .uri("/open-apis/bot/v3/info") + .token_types({lark.AccessTokenType.APP}) .build() ) response = self._client.request(request) @@ -455,12 +460,12 @@ class FeishuChannel(BaseChannel): if not key or key not in text: continue - userID = mention.id or None - if not userID: + user_id_obj = mention.id or None + if not user_id_obj: continue - open_id = userID.open_id - user_id = userID.user_id + open_id = user_id_obj.open_id + user_id = user_id_obj.user_id name = mention.name or key # Format: @姓名 (open_id, user_id: xxx) @@ -1576,7 +1581,6 @@ class FeishuChannel(BaseChannel): "thread_id": thread_id, }, ) - await self._del_reaction(message_id, reaction_id) except Exception as e: logger.error("Error processing Feishu message: {}", e) @@ -1585,6 +1589,10 @@ class FeishuChannel(BaseChannel): """Ignore reaction events so they do not generate SDK noise.""" pass + def _on_reaction_deleted(self, data: Any) -> None: + """Ignore reaction deleted events so they do not generate SDK noise.""" + pass + def _on_message_read(self, data: Any) -> None: """Ignore read events so they do not generate SDK noise.""" pass From 0355f20919f9f64cec1024bdd8d27acc8f28d444 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 7 Apr 2026 06:02:18 +0000 Subject: [PATCH 78/85] test: add regression tests for _resolve_mentions 7 tests covering: single mention, dual IDs, no-id skip, multiple mentions, no mentions, empty text, and key-not-in-text edge case. Made-with: Cursor --- tests/channels/test_feishu_mentions.py | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/channels/test_feishu_mentions.py diff --git a/tests/channels/test_feishu_mentions.py b/tests/channels/test_feishu_mentions.py new file mode 100644 index 000000000..a49e76ee6 --- /dev/null +++ b/tests/channels/test_feishu_mentions.py @@ -0,0 +1,59 @@ +"""Tests for FeishuChannel._resolve_mentions.""" + +from types import SimpleNamespace + +from nanobot.channels.feishu import FeishuChannel + + +def _mention(key: str, name: str, open_id: str = "", user_id: str = ""): + """Build a mock MentionEvent-like object.""" + id_obj = SimpleNamespace(open_id=open_id, user_id=user_id) if (open_id or user_id) else None + return SimpleNamespace(key=key, name=name, id=id_obj) + + +class TestResolveMentions: + def test_single_mention_replaced(self): + text = "hello @_user_1 how are you" + mentions = [_mention("@_user_1", "Alice", open_id="ou_abc123")] + result = FeishuChannel._resolve_mentions(text, mentions) + assert "@Alice (ou_abc123)" in result + assert "@_user_1" not in result + + def test_mention_with_both_ids(self): + text = "@_user_1 said hi" + mentions = [_mention("@_user_1", "Bob", open_id="ou_abc", user_id="uid_456")] + result = FeishuChannel._resolve_mentions(text, mentions) + assert "@Bob (ou_abc, user id: uid_456)" in result + + def test_mention_no_id_skipped(self): + """When mention has no id object, the placeholder is left unchanged.""" + text = "@_user_1 said hi" + mentions = [SimpleNamespace(key="@_user_1", name="Charlie", id=None)] + result = FeishuChannel._resolve_mentions(text, mentions) + assert result == "@_user_1 said hi" + + def test_multiple_mentions(self): + text = "@_user_1 and @_user_2 are here" + mentions = [ + _mention("@_user_1", "Alice", open_id="ou_a"), + _mention("@_user_2", "Bob", open_id="ou_b"), + ] + result = FeishuChannel._resolve_mentions(text, mentions) + assert "@Alice (ou_a)" in result + assert "@Bob (ou_b)" in result + assert "@_user_1" not in result + assert "@_user_2" not in result + + def test_no_mentions_returns_text(self): + assert FeishuChannel._resolve_mentions("hello world", None) == "hello world" + assert FeishuChannel._resolve_mentions("hello world", []) == "hello world" + + def test_empty_text_returns_empty(self): + mentions = [_mention("@_user_1", "Alice", open_id="ou_a")] + assert FeishuChannel._resolve_mentions("", mentions) == "" + + def test_mention_key_not_in_text_skipped(self): + text = "hello world" + mentions = [_mention("@_user_99", "Ghost", open_id="ou_ghost")] + result = FeishuChannel._resolve_mentions(text, mentions) + assert result == "hello world" From 02597c3ec999b8e6f1cb9840fc25c75964d885fb Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 7 Apr 2026 06:58:54 +0000 Subject: [PATCH 79/85] fix(runner): silent retry on empty response before finalization --- nanobot/agent/runner.py | 19 ++++++++- nanobot/utils/runtime.py | 3 +- tests/agent/test_runner.py | 80 ++++++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 12dd2287b..fbc2a4788 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -30,6 +30,7 @@ from nanobot.utils.runtime import ( ) _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." +_MAX_EMPTY_RETRIES = 2 _SNIP_SAFETY_BUFFER = 1024 @dataclass(slots=True) class AgentRunSpec: @@ -86,6 +87,7 @@ class AgentRunner: stop_reason = "completed" tool_events: list[dict[str, str]] = [] external_lookup_counts: dict[str, int] = {} + empty_content_retries = 0 for iteration in range(spec.max_iterations): try: @@ -178,15 +180,30 @@ class AgentRunner: "pending_tool_calls": [], }, ) + empty_content_retries = 0 await hook.after_iteration(context) continue clean = hook.finalize_content(context, response.content) if response.finish_reason != "error" and is_blank_text(clean): + empty_content_retries += 1 + if empty_content_retries < _MAX_EMPTY_RETRIES: + logger.warning( + "Empty response on turn {} for {} ({}/{}); retrying", + iteration, + spec.session_key or "default", + empty_content_retries, + _MAX_EMPTY_RETRIES, + ) + if hook.wants_streaming(): + await hook.on_stream_end(context, resuming=False) + await hook.after_iteration(context) + continue logger.warning( - "Empty final response on turn {} for {}; retrying with explicit finalization prompt", + "Empty response on turn {} for {} after {} retries; attempting finalization", iteration, spec.session_key or "default", + empty_content_retries, ) if hook.wants_streaming(): await hook.on_stream_end(context, resuming=False) diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py index 7164629c5..25d456955 100644 --- a/nanobot/utils/runtime.py +++ b/nanobot/utils/runtime.py @@ -16,8 +16,7 @@ EMPTY_FINAL_RESPONSE_MESSAGE = ( ) FINALIZATION_RETRY_PROMPT = ( - "You have already finished the tool work. Do not call any more tools. " - "Using only the conversation and tool results above, provide the final answer for the user now." + "Please provide your response to the user based on the conversation above." ) diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index dcdd15031..a700f495b 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -458,6 +458,7 @@ async def test_runner_uses_raw_messages_when_context_governance_fails(): @pytest.mark.asyncio async def test_runner_retries_empty_final_response_with_summary_prompt(): + """Empty responses get 2 silent retries before finalization kicks in.""" from nanobot.agent.runner import AgentRunSpec, AgentRunner provider = MagicMock() @@ -465,11 +466,11 @@ async def test_runner_retries_empty_final_response_with_summary_prompt(): async def chat_with_retry(*, messages, tools=None, **kwargs): calls.append({"messages": messages, "tools": tools}) - if len(calls) == 1: + if len(calls) <= 2: return LLMResponse( content=None, tool_calls=[], - usage={"prompt_tokens": 10, "completion_tokens": 1}, + usage={"prompt_tokens": 5, "completion_tokens": 1}, ) return LLMResponse( content="final answer", @@ -486,20 +487,23 @@ async def test_runner_retries_empty_final_response_with_summary_prompt(): initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", - max_iterations=1, + max_iterations=3, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) assert result.final_content == "final answer" - assert len(calls) == 2 - assert calls[1]["tools"] is None - assert "Do not call any more tools" in calls[1]["messages"][-1]["content"] + # 2 silent retries (iterations 0,1) + finalization on iteration 1 + assert len(calls) == 3 + assert calls[0]["tools"] is not None + assert calls[1]["tools"] is not None + assert calls[2]["tools"] is None assert result.usage["prompt_tokens"] == 13 - assert result.usage["completion_tokens"] == 8 + assert result.usage["completion_tokens"] == 9 @pytest.mark.asyncio async def test_runner_uses_specific_message_after_empty_finalization_retry(): + """After silent retries + finalization all return empty, stop_reason is empty_final_response.""" from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE @@ -517,7 +521,7 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry(): initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", - max_iterations=1, + max_iterations=3, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) @@ -525,6 +529,66 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry(): assert result.stop_reason == "empty_final_response" +@pytest.mark.asyncio +async def test_runner_empty_response_does_not_break_tool_chain(): + """An empty intermediate response must not kill an ongoing tool chain. + + Sequence: tool_call → empty → tool_call → final text. + The runner should recover via silent retry and complete normally. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + call_count = 0 + + async def chat_with_retry(*, messages, tools=None, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse( + content=None, + tool_calls=[ToolCallRequest(id="tc1", name="read_file", arguments={"path": "a.txt"})], + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + if call_count == 2: + return LLMResponse(content=None, tool_calls=[], usage={"prompt_tokens": 10, "completion_tokens": 1}) + if call_count == 3: + return LLMResponse( + content=None, + tool_calls=[ToolCallRequest(id="tc2", name="read_file", arguments={"path": "b.txt"})], + usage={"prompt_tokens": 10, "completion_tokens": 5}, + ) + return LLMResponse( + content="Here are the results.", + tool_calls=[], + usage={"prompt_tokens": 10, "completion_tokens": 10}, + ) + + provider.chat_with_retry = chat_with_retry + provider.chat_stream_with_retry = chat_with_retry + + async def fake_tool(name, args, **kw): + return "file content" + + tool_registry = MagicMock() + tool_registry.get_definitions.return_value = [{"type": "function", "function": {"name": "read_file"}}] + tool_registry.execute = AsyncMock(side_effect=fake_tool) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "read both files"}], + tools=tool_registry, + model="test-model", + max_iterations=10, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert result.final_content == "Here are the results." + assert result.stop_reason == "completed" + assert call_count == 4 + assert "read_file" in result.tools_used + + def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch): from nanobot.agent.runner import AgentRunSpec, AgentRunner From f452af6c62e9c3b2d1bdef81897f7b41672e01d9 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 11:31:00 +0800 Subject: [PATCH 80/85] feat(utils): add abbreviate_path for smart path/URL truncation --- nanobot/utils/path.py | 106 ++++++++++++++++++++++++++++ tests/utils/test_abbreviate_path.py | 79 +++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 nanobot/utils/path.py create mode 100644 tests/utils/test_abbreviate_path.py diff --git a/nanobot/utils/path.py b/nanobot/utils/path.py new file mode 100644 index 000000000..d23836e08 --- /dev/null +++ b/nanobot/utils/path.py @@ -0,0 +1,106 @@ +"""Path abbreviation utilities for display.""" + +from __future__ import annotations + +import os +import re +from urllib.parse import urlparse + + +def abbreviate_path(path: str, max_len: int = 40) -> str: + """Abbreviate a file path or URL, preserving basename and key directories. + + Strategy: + 1. Return as-is if short enough + 2. Replace home directory with ~/ + 3. From right, keep basename + parent dirs until budget exhausted + 4. Prefix with …/ + """ + if not path: + return path + + # Handle URLs: preserve scheme://domain + filename + if re.match(r"https?://", path): + return _abbreviate_url(path, max_len) + + # Normalize separators to / + normalized = path.replace("\\", "/") + + # Replace home directory + home = os.path.expanduser("~").replace("\\", "/") + if normalized.startswith(home + "/"): + normalized = "~" + normalized[len(home):] + elif normalized == home: + normalized = "~" + + # Return early only after normalization and home replacement + if len(normalized) <= max_len: + return normalized + + # Split into segments + parts = normalized.rstrip("/").split("/") + if len(parts) <= 1: + return normalized[:max_len - 1] + "\u2026" + + # Always keep the basename + basename = parts[-1] + # Budget: max_len minus "…/" prefix (2 chars) minus "/" separator minus basename + budget = max_len - len(basename) - 3 # -3 for "…/" + final "/" + + # Walk backwards from parent, collecting segments + kept: list[str] = [] + for seg in reversed(parts[:-1]): + needed = len(seg) + 1 # segment + "/" + if not kept and needed <= budget: + kept.append(seg) + budget -= needed + elif kept: + needed_with_sep = len(seg) + 1 + if needed_with_sep <= budget: + kept.append(seg) + budget -= needed_with_sep + else: + break + else: + break + + kept.reverse() + if kept: + return "\u2026/" + "/".join(kept) + "/" + basename + return "\u2026/" + basename + + +def _abbreviate_url(url: str, max_len: int = 40) -> str: + """Abbreviate a URL keeping domain and filename.""" + if len(url) <= max_len: + return url + + parsed = urlparse(url) + domain = parsed.netloc # e.g. "example.com" + path_part = parsed.path # e.g. "/api/v2/resource.json" + + # Extract filename from path + segments = path_part.rstrip("/").split("/") + basename = segments[-1] if segments else "" + + if not basename: + # No filename, truncate URL + return url[: max_len - 1] + "\u2026" + + budget = max_len - len(domain) - len(basename) - 4 # "…/" + "/" + if budget < 0: + return domain + "/\u2026" + basename[: max_len - len(domain) - 4] + + # Build abbreviated path + kept: list[str] = [] + for seg in reversed(segments[:-1]): + if len(seg) + 1 <= budget: + kept.append(seg) + budget -= len(seg) + 1 + else: + break + + kept.reverse() + if kept: + return domain + "/\u2026/" + "/".join(kept) + "/" + basename + return domain + "/\u2026/" + basename diff --git a/tests/utils/test_abbreviate_path.py b/tests/utils/test_abbreviate_path.py new file mode 100644 index 000000000..d8ee0a186 --- /dev/null +++ b/tests/utils/test_abbreviate_path.py @@ -0,0 +1,79 @@ +"""Tests for abbreviate_path utility.""" + +import os +from nanobot.utils.path import abbreviate_path + + +class TestAbbreviatePathShort: + def test_short_path_unchanged(self): + assert abbreviate_path("/home/user/file.py") == "/home/user/file.py" + + def test_exact_max_len_unchanged(self): + path = "/a/b/c" # 7 chars + assert abbreviate_path("/a/b/c", max_len=7) == "/a/b/c" + + def test_basename_only(self): + assert abbreviate_path("file.py") == "file.py" + + def test_empty_string(self): + assert abbreviate_path("") == "" + + +class TestAbbreviatePathHome: + def test_home_replacement(self): + home = os.path.expanduser("~") + result = abbreviate_path(f"{home}/project/file.py") + assert result.startswith("~/") + assert result.endswith("file.py") + + def test_home_preserves_short_path(self): + home = os.path.expanduser("~") + result = abbreviate_path(f"{home}/a.py") + assert result == "~/a.py" + + +class TestAbbreviatePathLong: + def test_long_path_keeps_basename(self): + path = "/a/b/c/d/e/f/g/h/very_long_filename.py" + result = abbreviate_path(path, max_len=30) + assert result.endswith("very_long_filename.py") + assert "\u2026" in result + + def test_long_path_keeps_parent_dir(self): + path = "/a/b/c/d/e/f/g/h/src/loop.py" + result = abbreviate_path(path, max_len=30) + assert "loop.py" in result + assert "src" in result + + def test_very_long_path_just_basename(self): + path = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.py" + result = abbreviate_path(path, max_len=20) + assert result.endswith("file.py") + assert len(result) <= 20 + + +class TestAbbreviatePathWindows: + def test_windows_drive_path(self): + path = "D:\\Documents\\GitHub\\nanobot\\src\\utils\\helpers.py" + result = abbreviate_path(path, max_len=40) + assert result.endswith("helpers.py") + assert "nanobot" in result + + def test_windows_home(self): + home = os.path.expanduser("~") + path = os.path.join(home, ".nanobot", "workspace", "log.txt") + result = abbreviate_path(path) + assert result.startswith("~/") + assert "log.txt" in result + + +class TestAbbreviatePathURLs: + def test_url_keeps_domain_and_filename(self): + url = "https://example.com/api/v2/long/path/resource.json" + result = abbreviate_path(url, max_len=40) + assert "resource.json" in result + assert "example.com" in result + + def test_short_url_unchanged(self): + url = "https://example.com/api" + assert abbreviate_path(url) == url From 8ca99600775e0150f1d6004feafe6a0587329add Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 11:32:28 +0800 Subject: [PATCH 81/85] feat(agent): rewrite _tool_hint with registry, path abbreviation, and call folding --- nanobot/agent/loop.py | 101 ++++++++++++++++++++++- tests/agent/test_tool_hint.py | 149 ++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/agent/test_tool_hint.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9e5ca0bc9..e94999a57 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -325,14 +325,107 @@ class AgentLoop: @staticmethod def _tool_hint(tool_calls: list) -> str: - """Format tool calls as concise hint, e.g. 'web_search("query")'.""" - def _fmt(tc): + """Format tool calls as concise hints with smart abbreviation.""" + if not tool_calls: + return "" + + from nanobot.utils.path import abbreviate_path + + def _group_consecutive(calls): + """Group consecutive calls to the same tool: [(name, count, first), ...].""" + groups = [] + for tc in calls: + if groups and groups[-1][0] == tc.name: + groups[-1] = (groups[-1][0], groups[-1][1] + 1, groups[-1][2]) + else: + groups.append((tc.name, 1, tc)) + return groups + + def _extract_arg(tc, key_args): + """Extract the first available value from preferred key names.""" + args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + if not isinstance(args, dict): + return None + for key in key_args: + val = args.get(key) + if isinstance(val, str) and val: + return val + # Fallback: first string value + for val in args.values(): + if isinstance(val, str) and val: + return val + return None + + def _fmt_known(tc, fmt): + """Format a registered tool using its template.""" + val = _extract_arg(tc, fmt[0]) + if val is None: + return tc.name + if fmt[2]: # is_path + val = abbreviate_path(val) + elif fmt[3]: # is_command + val = val[:40] + "\u2026" if len(val) > 40 else val + template = fmt[1] + if '"{}"' in template: + return template.format(val) + return template.format(val) + + def _fmt_mcp(tc): + """Format MCP tool as server::tool.""" + name = tc.name + # mcp___ or mcp__ + if "__" in name: + parts = name.split("__", 1) + server = parts[0].removeprefix("mcp_") + tool = parts[1] + else: + rest = name.removeprefix("mcp_") + parts = rest.split("_", 1) + server = parts[0] if parts else rest + tool = parts[1] if len(parts) > 1 else "" + if not tool: + return name + args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + val = next((v for v in args.values() if isinstance(v, str) and v), None) + if val is None: + return f"{server}::{tool}" + return f'{server}::{tool}("{abbreviate_path(val, 40)}")' + + def _fmt_fallback(tc): + """Original formatting logic for unregistered tools.""" args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name - return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' - return ", ".join(_fmt(tc) for tc in tool_calls) + return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' + + # Registry: tool_name -> (key_args, template, is_path, is_command) + FORMATS: dict[str, tuple[list[str], str, bool, bool]] = { + "read_file": (["path", "file_path"], "read {}", True, False), + "write_file": (["path", "file_path"], "write {}", True, False), + "edit": (["file_path", "path"], "edit {}", True, False), + "glob": (["pattern"], 'glob "{}"', False, False), + "grep": (["pattern"], 'grep "{}"', False, False), + "exec": (["command"], "$ {}", False, True), + "web_search": (["query"], 'search "{}"', False, False), + "web_fetch": (["url"], "fetch {}", True, False), + } + + hints = [] + for name, count, example_tc in _group_consecutive(tool_calls): + fmt = FORMATS.get(name) + if fmt: + hint = _fmt_known(example_tc, fmt) + elif name.startswith("mcp_"): + hint = _fmt_mcp(example_tc) + else: + hint = _fmt_fallback(example_tc) + + if count > 1: + hint = f"{hint} \u00d7 {count}" + hints.append(hint) + + return ", ".join(hints) async def _run_agent_loop( self, diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py new file mode 100644 index 000000000..f0c324083 --- /dev/null +++ b/tests/agent/test_tool_hint.py @@ -0,0 +1,149 @@ +"""Tests for AgentLoop._tool_hint() formatting.""" + +from nanobot.agent.loop import AgentLoop +from nanobot.providers.base import ToolCallRequest + + +def _tc(name: str, args: dict) -> ToolCallRequest: + return ToolCallRequest(id="c1", name=name, arguments=args) + + +class TestToolHintKnownTools: + """Test registered tool types produce correct formatted output.""" + + def test_read_file_short_path(self): + result = AgentLoop._tool_hint([_tc("read_file", {"path": "foo.txt"})]) + assert result == 'read foo.txt' + + def test_read_file_long_path(self): + result = AgentLoop._tool_hint([_tc("read_file", {"path": "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"})]) + assert "loop.py" in result + assert "read " in result + + def test_write_file_shows_path_not_content(self): + result = AgentLoop._tool_hint([_tc("write_file", {"path": "docs/api.md", "content": "# API Reference\n\nLong content..."})]) + assert result == "write docs/api.md" + + def test_edit_shows_path(self): + result = AgentLoop._tool_hint([_tc("edit", {"file_path": "src/main.py", "old_string": "x", "new_string": "y"})]) + assert "main.py" in result + assert "edit " in result + + def test_glob_shows_pattern(self): + result = AgentLoop._tool_hint([_tc("glob", {"pattern": "**/*.py", "path": "src"})]) + assert result == 'glob "**/*.py"' + + def test_grep_shows_pattern(self): + result = AgentLoop._tool_hint([_tc("grep", {"pattern": "TODO|FIXME", "path": "src"})]) + assert result == 'grep "TODO|FIXME"' + + def test_exec_shows_command(self): + result = AgentLoop._tool_hint([_tc("exec", {"command": "npm install typescript"})]) + assert result == "$ npm install typescript" + + def test_exec_truncates_long_command(self): + cmd = "cd /very/long/path && cat file && echo done && sleep 1 && ls -la" + result = AgentLoop._tool_hint([_tc("exec", {"command": cmd})]) + assert result.startswith("$ ") + assert len(result) <= 50 # reasonable limit + + def test_web_search(self): + result = AgentLoop._tool_hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})]) + assert result == 'search "Claude 4 vs GPT-4"' + + def test_web_fetch(self): + result = AgentLoop._tool_hint([_tc("web_fetch", {"url": "https://example.com/page"})]) + assert result == "fetch https://example.com/page" + + +class TestToolHintMCP: + """Test MCP tools are abbreviated to server::tool format.""" + + def test_mcp_standard_format(self): + result = AgentLoop._tool_hint([_tc("mcp_4_5v_mcp__analyze_image", {"imageSource": "https://img.jpg", "prompt": "describe"})]) + assert "4_5v" in result + assert "analyze_image" in result + + def test_mcp_simple_name(self): + result = AgentLoop._tool_hint([_tc("mcp_github__create_issue", {"title": "Bug fix"})]) + assert "github" in result + assert "create_issue" in result + + +class TestToolHintFallback: + """Test unknown tools fall back to original behavior.""" + + def test_unknown_tool_with_string_arg(self): + result = AgentLoop._tool_hint([_tc("custom_tool", {"data": "hello world"})]) + assert result == 'custom_tool("hello world")' + + def test_unknown_tool_with_long_arg_truncates(self): + long_val = "a" * 60 + result = AgentLoop._tool_hint([_tc("custom_tool", {"data": long_val})]) + assert len(result) < 80 + assert "\u2026" in result + + def test_unknown_tool_no_string_arg(self): + result = AgentLoop._tool_hint([_tc("custom_tool", {"count": 42})]) + assert result == "custom_tool" + + def test_empty_tool_calls(self): + result = AgentLoop._tool_hint([]) + assert result == "" + + +class TestToolHintFolding: + """Test consecutive same-tool calls are folded.""" + + def test_single_call_no_fold(self): + calls = [_tc("grep", {"pattern": "*.py"})] + result = AgentLoop._tool_hint(calls) + assert "\u00d7" not in result + + def test_two_consecutive_same_folded(self): + calls = [ + _tc("grep", {"pattern": "*.py"}), + _tc("grep", {"pattern": "*.ts"}), + ] + result = AgentLoop._tool_hint(calls) + assert "\u00d7 2" in result + + def test_three_consecutive_same_folded(self): + calls = [ + _tc("read_file", {"path": "a.py"}), + _tc("read_file", {"path": "b.py"}), + _tc("read_file", {"path": "c.py"}), + ] + result = AgentLoop._tool_hint(calls) + assert "\u00d7 3" in result + + def test_different_tools_not_folded(self): + calls = [ + _tc("grep", {"pattern": "TODO"}), + _tc("read_file", {"path": "a.py"}), + ] + result = AgentLoop._tool_hint(calls) + assert "\u00d7" not in result + + def test_interleaved_same_tools_not_folded(self): + calls = [ + _tc("grep", {"pattern": "a"}), + _tc("read_file", {"path": "f.py"}), + _tc("grep", {"pattern": "b"}), + ] + result = AgentLoop._tool_hint(calls) + assert "\u00d7" not in result + + +class TestToolHintMultipleCalls: + """Test multiple different tool calls are comma-separated.""" + + def test_two_different_tools(self): + calls = [ + _tc("grep", {"pattern": "TODO"}), + _tc("read_file", {"path": "main.py"}), + ] + result = AgentLoop._tool_hint(calls) + assert 'grep "TODO"' in result + assert "read main.py" in result + assert ", " in result From 238a9303d0614c93019d287d801cc04624bc1ce0 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 11:33:01 +0800 Subject: [PATCH 82/85] test: update tool_hint assertion to match new format --- tests/tools/test_message_tool_suppress.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tools/test_message_tool_suppress.py b/tests/tools/test_message_tool_suppress.py index 1091de4c7..26d12085f 100644 --- a/tests/tools/test_message_tool_suppress.py +++ b/tests/tools/test_message_tool_suppress.py @@ -112,7 +112,7 @@ class TestMessageToolSuppressLogic: assert final_content == "Done" assert progress == [ ("Visible", False), - ('read_file("foo.txt")', True), + ('read foo.txt', True), ] From b1d3c00deb5cbce5cb797f185912a5d58a296cf0 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 11:34:54 +0800 Subject: [PATCH 83/85] test(feishu): add compatibility tests for new tool hint format --- .../test_feishu_tool_hint_code_block.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/channels/test_feishu_tool_hint_code_block.py b/tests/channels/test_feishu_tool_hint_code_block.py index a65f1d988..a5db5ad69 100644 --- a/tests/channels/test_feishu_tool_hint_code_block.py +++ b/tests/channels/test_feishu_tool_hint_code_block.py @@ -127,6 +127,79 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): @mark.asyncio +async def test_tool_hint_new_format_basic(mock_feishu_channel): + """New format hints (read path, grep "pattern") should parse correctly.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='read src/main.py, grep "TODO"', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + content = json.loads(mock_send.call_args[0][3]) + md = content["elements"][0]["content"] + assert "read src/main.py" in md + assert 'grep "TODO"' in md + + +@mark.asyncio +async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel): + """Commas inside quoted arguments must not cause incorrect line splits.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='grep "hello, world", $ echo test', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + content = json.loads(mock_send.call_args[0][3]) + md = content["elements"][0]["content"] + # The comma inside quotes should NOT cause a line break + assert 'grep "hello, world"' in md + assert "$ echo test" in md + + +@mark.asyncio +async def test_tool_hint_new_format_with_folding(mock_feishu_channel): + """Folded calls (× N) should display on separate lines.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='read path × 3, grep "pattern"', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + content = json.loads(mock_send.call_args[0][3]) + md = content["elements"][0]["content"] + assert "\u00d7 3" in md + assert 'grep "pattern"' in md + + +@mark.asyncio +async def test_tool_hint_new_format_mcp(mock_feishu_channel): + """MCP tool format (server::tool) should parse correctly.""" + msg = OutboundMessage( + channel="feishu", + chat_id="oc_123456", + content='4_5v::analyze_image("photo.jpg")', + metadata={"_tool_hint": True} + ) + + with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: + await mock_feishu_channel.send(msg) + + content = json.loads(mock_send.call_args[0][3]) + md = content["elements"][0]["content"] + assert "4_5v::analyze_image" in md async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel): """Commas inside a single tool argument must not be split onto a new line.""" msg = OutboundMessage( From 3e3a7654f85f8652f0db6e6dcec1e8e9ceb94d9a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 14:18:53 +0800 Subject: [PATCH 84/85] fix(agent): address code review findings for tool hint enhancement - C1: Fix IndexError on empty list arguments via _get_args() helper - I1: Remove redundant branch in _fmt_known - I2: Export abbreviate_path from nanobot.utils.__init__ - I3: Fix _abbreviate_url negative-budget format consistency - S1: Move FORMATS to class-level _TOOL_HINT_FORMATS constant - S2: Add list_dir to FORMATS registry (ls path) - G1-G5: Add tests for empty list args, None args, URL edge cases, mixed folding groups, and list_dir format --- nanobot/agent/loop.py | 48 +++++++++++++++------------ nanobot/utils/__init__.py | 3 +- nanobot/utils/path.py | 3 +- tests/agent/test_tool_hint.py | 50 ++++++++++++++++++++++++++++- tests/utils/test_abbreviate_path.py | 26 +++++++++++++++ 5 files changed, 107 insertions(+), 23 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e94999a57..e58156758 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -323,6 +323,19 @@ class AgentLoop: from nanobot.utils.helpers import strip_think return strip_think(text) or None + # Registry: tool_name -> (key_args, template, is_path, is_command) + _TOOL_HINT_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = { + "read_file": (["path", "file_path"], "read {}", True, False), + "write_file": (["path", "file_path"], "write {}", True, False), + "edit": (["file_path", "path"], "edit {}", True, False), + "glob": (["pattern"], 'glob "{}"', False, False), + "grep": (["pattern"], 'grep "{}"', False, False), + "exec": (["command"], "$ {}", False, True), + "web_search": (["query"], 'search "{}"', False, False), + "web_fetch": (["url"], "fetch {}", True, False), + "list_dir": (["path"], "ls {}", True, False), + } + @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hints with smart abbreviation.""" @@ -331,6 +344,16 @@ class AgentLoop: from nanobot.utils.path import abbreviate_path + def _get_args(tc) -> dict: + """Extract args dict from tc.arguments, handling list/dict/None/empty.""" + if tc.arguments is None: + return {} + if isinstance(tc.arguments, list): + return tc.arguments[0] if tc.arguments else {} + if isinstance(tc.arguments, dict): + return tc.arguments + return {} + def _group_consecutive(calls): """Group consecutive calls to the same tool: [(name, count, first), ...].""" groups = [] @@ -343,7 +366,7 @@ class AgentLoop: def _extract_arg(tc, key_args): """Extract the first available value from preferred key names.""" - args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + args = _get_args(tc) if not isinstance(args, dict): return None for key in key_args: @@ -365,10 +388,7 @@ class AgentLoop: val = abbreviate_path(val) elif fmt[3]: # is_command val = val[:40] + "\u2026" if len(val) > 40 else val - template = fmt[1] - if '"{}"' in template: - return template.format(val) - return template.format(val) + return fmt[1].format(val) def _fmt_mcp(tc): """Format MCP tool as server::tool.""" @@ -385,7 +405,7 @@ class AgentLoop: tool = parts[1] if len(parts) > 1 else "" if not tool: return name - args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + args = _get_args(tc) val = next((v for v in args.values() if isinstance(v, str) and v), None) if val is None: return f"{server}::{tool}" @@ -393,27 +413,15 @@ class AgentLoop: def _fmt_fallback(tc): """Original formatting logic for unregistered tools.""" - args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {} + args = _get_args(tc) val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' - # Registry: tool_name -> (key_args, template, is_path, is_command) - FORMATS: dict[str, tuple[list[str], str, bool, bool]] = { - "read_file": (["path", "file_path"], "read {}", True, False), - "write_file": (["path", "file_path"], "write {}", True, False), - "edit": (["file_path", "path"], "edit {}", True, False), - "glob": (["pattern"], 'glob "{}"', False, False), - "grep": (["pattern"], 'grep "{}"', False, False), - "exec": (["command"], "$ {}", False, True), - "web_search": (["query"], 'search "{}"', False, False), - "web_fetch": (["url"], "fetch {}", True, False), - } - hints = [] for name, count, example_tc in _group_consecutive(tool_calls): - fmt = FORMATS.get(name) + fmt = AgentLoop._TOOL_HINT_FORMATS.get(name) if fmt: hint = _fmt_known(example_tc, fmt) elif name.startswith("mcp_"): diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 46f02acbd..9ad157c2e 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,5 +1,6 @@ """Utility functions for nanobot.""" from nanobot.utils.helpers import ensure_dir +from nanobot.utils.path import abbreviate_path -__all__ = ["ensure_dir"] +__all__ = ["ensure_dir", "abbreviate_path"] diff --git a/nanobot/utils/path.py b/nanobot/utils/path.py index d23836e08..32591a471 100644 --- a/nanobot/utils/path.py +++ b/nanobot/utils/path.py @@ -89,7 +89,8 @@ def _abbreviate_url(url: str, max_len: int = 40) -> str: budget = max_len - len(domain) - len(basename) - 4 # "…/" + "/" if budget < 0: - return domain + "/\u2026" + basename[: max_len - len(domain) - 4] + trunc = max_len - len(domain) - 5 # "…/" + "/" + return domain + "/\u2026/" + (basename[:trunc] if trunc > 0 else "") # Build abbreviated path kept: list[str] = [] diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index f0c324083..caaa37ee3 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -4,7 +4,7 @@ from nanobot.agent.loop import AgentLoop from nanobot.providers.base import ToolCallRequest -def _tc(name: str, args: dict) -> ToolCallRequest: +def _tc(name: str, args) -> ToolCallRequest: return ToolCallRequest(id="c1", name=name, arguments=args) @@ -147,3 +147,51 @@ class TestToolHintMultipleCalls: assert 'grep "TODO"' in result assert "read main.py" in result assert ", " in result + + +class TestToolHintEdgeCases: + """Test edge cases and defensive handling (G1, G2).""" + + def test_known_tool_empty_list_args(self): + """C1/G1: Empty list arguments should not crash.""" + result = AgentLoop._tool_hint([_tc("read_file", [])]) + assert result == "read_file" + + def test_known_tool_none_args(self): + """G2: None arguments should not crash.""" + result = AgentLoop._tool_hint([_tc("read_file", None)]) + assert result == "read_file" + + def test_fallback_empty_list_args(self): + """C1: Empty list args in fallback should not crash.""" + result = AgentLoop._tool_hint([_tc("custom_tool", [])]) + assert result == "custom_tool" + + def test_fallback_none_args(self): + """G2: None args in fallback should not crash.""" + result = AgentLoop._tool_hint([_tc("custom_tool", None)]) + assert result == "custom_tool" + + def test_list_dir_registered(self): + """S2: list_dir should use 'ls' format.""" + result = AgentLoop._tool_hint([_tc("list_dir", {"path": "/tmp"})]) + assert result == "ls /tmp" + + +class TestToolHintMixedFolding: + """G4: Mixed folding groups with interleaved same-tool segments.""" + + def test_read_read_grep_grep_read(self): + """read×2, grep×2, read — should produce two separate groups.""" + calls = [ + _tc("read_file", {"path": "a.py"}), + _tc("read_file", {"path": "b.py"}), + _tc("grep", {"pattern": "x"}), + _tc("grep", {"pattern": "y"}), + _tc("read_file", {"path": "c.py"}), + ] + result = AgentLoop._tool_hint(calls) + assert "\u00d7 2" in result + # Should have 3 groups: read×2, grep×2, read + parts = result.split(", ") + assert len(parts) == 3 diff --git a/tests/utils/test_abbreviate_path.py b/tests/utils/test_abbreviate_path.py index d8ee0a186..573ca0a92 100644 --- a/tests/utils/test_abbreviate_path.py +++ b/tests/utils/test_abbreviate_path.py @@ -77,3 +77,29 @@ class TestAbbreviatePathURLs: def test_short_url_unchanged(self): url = "https://example.com/api" assert abbreviate_path(url) == url + + def test_url_no_path_just_domain(self): + """G3: URL with no path should return as-is if short enough.""" + url = "https://example.com" + assert abbreviate_path(url) == url + + def test_url_with_query_string(self): + """G3: URL with query params should abbreviate path part.""" + url = "https://example.com/api/v2/endpoint?key=value&other=123" + result = abbreviate_path(url, max_len=40) + assert "example.com" in result + assert "\u2026" in result + + def test_url_very_long_basename(self): + """G3: URL with very long basename should truncate basename.""" + url = "https://example.com/path/very_long_resource_name_file.json" + result = abbreviate_path(url, max_len=35) + assert "example.com" in result + assert "\u2026" in result + + def test_url_negative_budget_consistent_format(self): + """I3: Negative budget should still produce domain/…/basename format.""" + url = "https://a.co/very/deep/path/with/lots/of/segments/and/a/long/basename.txt" + result = abbreviate_path(url, max_len=20) + assert "a.co" in result + assert "/\u2026/" in result From 82dec12f6641fac66172fbf9337a39a674629c6e Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 7 Apr 2026 07:14:12 +0000 Subject: [PATCH 85/85] refactor: extract tool hint formatting to utils/tool_hints.py - Move _tool_hint implementation from loop.py to nanobot/utils/tool_hints.py - Keep thin delegation in AgentLoop._tool_hint for backward compat - Update test imports to test format_tool_hints directly Made-with: Cursor --- nanobot/agent/loop.py | 109 +------------------------------ nanobot/utils/tool_hints.py | 119 ++++++++++++++++++++++++++++++++++ tests/agent/test_tool_hint.py | 65 ++++++++++--------- 3 files changed, 156 insertions(+), 137 deletions(-) create mode 100644 nanobot/utils/tool_hints.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e58156758..66d765d00 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -323,117 +323,12 @@ class AgentLoop: from nanobot.utils.helpers import strip_think return strip_think(text) or None - # Registry: tool_name -> (key_args, template, is_path, is_command) - _TOOL_HINT_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = { - "read_file": (["path", "file_path"], "read {}", True, False), - "write_file": (["path", "file_path"], "write {}", True, False), - "edit": (["file_path", "path"], "edit {}", True, False), - "glob": (["pattern"], 'glob "{}"', False, False), - "grep": (["pattern"], 'grep "{}"', False, False), - "exec": (["command"], "$ {}", False, True), - "web_search": (["query"], 'search "{}"', False, False), - "web_fetch": (["url"], "fetch {}", True, False), - "list_dir": (["path"], "ls {}", True, False), - } - @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hints with smart abbreviation.""" - if not tool_calls: - return "" + from nanobot.utils.tool_hints import format_tool_hints - from nanobot.utils.path import abbreviate_path - - def _get_args(tc) -> dict: - """Extract args dict from tc.arguments, handling list/dict/None/empty.""" - if tc.arguments is None: - return {} - if isinstance(tc.arguments, list): - return tc.arguments[0] if tc.arguments else {} - if isinstance(tc.arguments, dict): - return tc.arguments - return {} - - def _group_consecutive(calls): - """Group consecutive calls to the same tool: [(name, count, first), ...].""" - groups = [] - for tc in calls: - if groups and groups[-1][0] == tc.name: - groups[-1] = (groups[-1][0], groups[-1][1] + 1, groups[-1][2]) - else: - groups.append((tc.name, 1, tc)) - return groups - - def _extract_arg(tc, key_args): - """Extract the first available value from preferred key names.""" - args = _get_args(tc) - if not isinstance(args, dict): - return None - for key in key_args: - val = args.get(key) - if isinstance(val, str) and val: - return val - # Fallback: first string value - for val in args.values(): - if isinstance(val, str) and val: - return val - return None - - def _fmt_known(tc, fmt): - """Format a registered tool using its template.""" - val = _extract_arg(tc, fmt[0]) - if val is None: - return tc.name - if fmt[2]: # is_path - val = abbreviate_path(val) - elif fmt[3]: # is_command - val = val[:40] + "\u2026" if len(val) > 40 else val - return fmt[1].format(val) - - def _fmt_mcp(tc): - """Format MCP tool as server::tool.""" - name = tc.name - # mcp___ or mcp__ - if "__" in name: - parts = name.split("__", 1) - server = parts[0].removeprefix("mcp_") - tool = parts[1] - else: - rest = name.removeprefix("mcp_") - parts = rest.split("_", 1) - server = parts[0] if parts else rest - tool = parts[1] if len(parts) > 1 else "" - if not tool: - return name - args = _get_args(tc) - val = next((v for v in args.values() if isinstance(v, str) and v), None) - if val is None: - return f"{server}::{tool}" - return f'{server}::{tool}("{abbreviate_path(val, 40)}")' - - def _fmt_fallback(tc): - """Original formatting logic for unregistered tools.""" - args = _get_args(tc) - val = next(iter(args.values()), None) if isinstance(args, dict) else None - if not isinstance(val, str): - return tc.name - return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' - - hints = [] - for name, count, example_tc in _group_consecutive(tool_calls): - fmt = AgentLoop._TOOL_HINT_FORMATS.get(name) - if fmt: - hint = _fmt_known(example_tc, fmt) - elif name.startswith("mcp_"): - hint = _fmt_mcp(example_tc) - else: - hint = _fmt_fallback(example_tc) - - if count > 1: - hint = f"{hint} \u00d7 {count}" - hints.append(hint) - - return ", ".join(hints) + return format_tool_hints(tool_calls) async def _run_agent_loop( self, diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py new file mode 100644 index 000000000..a907a2700 --- /dev/null +++ b/nanobot/utils/tool_hints.py @@ -0,0 +1,119 @@ +"""Tool hint formatting for concise, human-readable tool call display.""" + +from __future__ import annotations + +from nanobot.utils.path import abbreviate_path + +# Registry: tool_name -> (key_args, template, is_path, is_command) +_TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = { + "read_file": (["path", "file_path"], "read {}", True, False), + "write_file": (["path", "file_path"], "write {}", True, False), + "edit": (["file_path", "path"], "edit {}", True, False), + "glob": (["pattern"], 'glob "{}"', False, False), + "grep": (["pattern"], 'grep "{}"', False, False), + "exec": (["command"], "$ {}", False, True), + "web_search": (["query"], 'search "{}"', False, False), + "web_fetch": (["url"], "fetch {}", True, False), + "list_dir": (["path"], "ls {}", True, False), +} + + +def format_tool_hints(tool_calls: list) -> str: + """Format tool calls as concise hints with smart abbreviation.""" + if not tool_calls: + return "" + + hints = [] + for name, count, example_tc in _group_consecutive(tool_calls): + fmt = _TOOL_FORMATS.get(name) + if fmt: + hint = _fmt_known(example_tc, fmt) + elif name.startswith("mcp_"): + hint = _fmt_mcp(example_tc) + else: + hint = _fmt_fallback(example_tc) + + if count > 1: + hint = f"{hint} \u00d7 {count}" + hints.append(hint) + + return ", ".join(hints) + + +def _get_args(tc) -> dict: + """Extract args dict from tc.arguments, handling list/dict/None/empty.""" + if tc.arguments is None: + return {} + if isinstance(tc.arguments, list): + return tc.arguments[0] if tc.arguments else {} + if isinstance(tc.arguments, dict): + return tc.arguments + return {} + + +def _group_consecutive(calls: list) -> list[tuple[str, int, object]]: + """Group consecutive calls to the same tool: [(name, count, first), ...].""" + groups: list[tuple[str, int, object]] = [] + for tc in calls: + if groups and groups[-1][0] == tc.name: + groups[-1] = (groups[-1][0], groups[-1][1] + 1, groups[-1][2]) + else: + groups.append((tc.name, 1, tc)) + return groups + + +def _extract_arg(tc, key_args: list[str]) -> str | None: + """Extract the first available value from preferred key names.""" + args = _get_args(tc) + if not isinstance(args, dict): + return None + for key in key_args: + val = args.get(key) + if isinstance(val, str) and val: + return val + for val in args.values(): + if isinstance(val, str) and val: + return val + return None + + +def _fmt_known(tc, fmt: tuple) -> str: + """Format a registered tool using its template.""" + val = _extract_arg(tc, fmt[0]) + if val is None: + return tc.name + if fmt[2]: # is_path + val = abbreviate_path(val) + elif fmt[3]: # is_command + val = val[:40] + "\u2026" if len(val) > 40 else val + return fmt[1].format(val) + + +def _fmt_mcp(tc) -> str: + """Format MCP tool as server::tool.""" + name = tc.name + if "__" in name: + parts = name.split("__", 1) + server = parts[0].removeprefix("mcp_") + tool = parts[1] + else: + rest = name.removeprefix("mcp_") + parts = rest.split("_", 1) + server = parts[0] if parts else rest + tool = parts[1] if len(parts) > 1 else "" + if not tool: + return name + args = _get_args(tc) + val = next((v for v in args.values() if isinstance(v, str) and v), None) + if val is None: + return f"{server}::{tool}" + return f'{server}::{tool}("{abbreviate_path(val, 40)}")' + + +def _fmt_fallback(tc) -> str: + """Original formatting logic for unregistered tools.""" + args = _get_args(tc) + val = next(iter(args.values()), None) if isinstance(args, dict) else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index caaa37ee3..2384cfbb2 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -1,6 +1,6 @@ -"""Tests for AgentLoop._tool_hint() formatting.""" +"""Tests for tool hint formatting (nanobot.utils.tool_hints).""" -from nanobot.agent.loop import AgentLoop +from nanobot.utils.tool_hints import format_tool_hints from nanobot.providers.base import ToolCallRequest @@ -8,51 +8,56 @@ def _tc(name: str, args) -> ToolCallRequest: return ToolCallRequest(id="c1", name=name, arguments=args) +def _hint(calls): + """Shortcut for format_tool_hints.""" + return format_tool_hints(calls) + + class TestToolHintKnownTools: """Test registered tool types produce correct formatted output.""" def test_read_file_short_path(self): - result = AgentLoop._tool_hint([_tc("read_file", {"path": "foo.txt"})]) + result = _hint([_tc("read_file", {"path": "foo.txt"})]) assert result == 'read foo.txt' def test_read_file_long_path(self): - result = AgentLoop._tool_hint([_tc("read_file", {"path": "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"})]) + result = _hint([_tc("read_file", {"path": "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"})]) assert "loop.py" in result assert "read " in result def test_write_file_shows_path_not_content(self): - result = AgentLoop._tool_hint([_tc("write_file", {"path": "docs/api.md", "content": "# API Reference\n\nLong content..."})]) + result = _hint([_tc("write_file", {"path": "docs/api.md", "content": "# API Reference\n\nLong content..."})]) assert result == "write docs/api.md" def test_edit_shows_path(self): - result = AgentLoop._tool_hint([_tc("edit", {"file_path": "src/main.py", "old_string": "x", "new_string": "y"})]) + result = _hint([_tc("edit", {"file_path": "src/main.py", "old_string": "x", "new_string": "y"})]) assert "main.py" in result assert "edit " in result def test_glob_shows_pattern(self): - result = AgentLoop._tool_hint([_tc("glob", {"pattern": "**/*.py", "path": "src"})]) + result = _hint([_tc("glob", {"pattern": "**/*.py", "path": "src"})]) assert result == 'glob "**/*.py"' def test_grep_shows_pattern(self): - result = AgentLoop._tool_hint([_tc("grep", {"pattern": "TODO|FIXME", "path": "src"})]) + result = _hint([_tc("grep", {"pattern": "TODO|FIXME", "path": "src"})]) assert result == 'grep "TODO|FIXME"' def test_exec_shows_command(self): - result = AgentLoop._tool_hint([_tc("exec", {"command": "npm install typescript"})]) + result = _hint([_tc("exec", {"command": "npm install typescript"})]) assert result == "$ npm install typescript" def test_exec_truncates_long_command(self): cmd = "cd /very/long/path && cat file && echo done && sleep 1 && ls -la" - result = AgentLoop._tool_hint([_tc("exec", {"command": cmd})]) + result = _hint([_tc("exec", {"command": cmd})]) assert result.startswith("$ ") assert len(result) <= 50 # reasonable limit def test_web_search(self): - result = AgentLoop._tool_hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})]) + result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})]) assert result == 'search "Claude 4 vs GPT-4"' def test_web_fetch(self): - result = AgentLoop._tool_hint([_tc("web_fetch", {"url": "https://example.com/page"})]) + result = _hint([_tc("web_fetch", {"url": "https://example.com/page"})]) assert result == "fetch https://example.com/page" @@ -60,12 +65,12 @@ class TestToolHintMCP: """Test MCP tools are abbreviated to server::tool format.""" def test_mcp_standard_format(self): - result = AgentLoop._tool_hint([_tc("mcp_4_5v_mcp__analyze_image", {"imageSource": "https://img.jpg", "prompt": "describe"})]) + result = _hint([_tc("mcp_4_5v_mcp__analyze_image", {"imageSource": "https://img.jpg", "prompt": "describe"})]) assert "4_5v" in result assert "analyze_image" in result def test_mcp_simple_name(self): - result = AgentLoop._tool_hint([_tc("mcp_github__create_issue", {"title": "Bug fix"})]) + result = _hint([_tc("mcp_github__create_issue", {"title": "Bug fix"})]) assert "github" in result assert "create_issue" in result @@ -74,21 +79,21 @@ class TestToolHintFallback: """Test unknown tools fall back to original behavior.""" def test_unknown_tool_with_string_arg(self): - result = AgentLoop._tool_hint([_tc("custom_tool", {"data": "hello world"})]) + result = _hint([_tc("custom_tool", {"data": "hello world"})]) assert result == 'custom_tool("hello world")' def test_unknown_tool_with_long_arg_truncates(self): long_val = "a" * 60 - result = AgentLoop._tool_hint([_tc("custom_tool", {"data": long_val})]) + result = _hint([_tc("custom_tool", {"data": long_val})]) assert len(result) < 80 assert "\u2026" in result def test_unknown_tool_no_string_arg(self): - result = AgentLoop._tool_hint([_tc("custom_tool", {"count": 42})]) + result = _hint([_tc("custom_tool", {"count": 42})]) assert result == "custom_tool" def test_empty_tool_calls(self): - result = AgentLoop._tool_hint([]) + result = _hint([]) assert result == "" @@ -97,7 +102,7 @@ class TestToolHintFolding: def test_single_call_no_fold(self): calls = [_tc("grep", {"pattern": "*.py"})] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7" not in result def test_two_consecutive_same_folded(self): @@ -105,7 +110,7 @@ class TestToolHintFolding: _tc("grep", {"pattern": "*.py"}), _tc("grep", {"pattern": "*.ts"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7 2" in result def test_three_consecutive_same_folded(self): @@ -114,7 +119,7 @@ class TestToolHintFolding: _tc("read_file", {"path": "b.py"}), _tc("read_file", {"path": "c.py"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7 3" in result def test_different_tools_not_folded(self): @@ -122,7 +127,7 @@ class TestToolHintFolding: _tc("grep", {"pattern": "TODO"}), _tc("read_file", {"path": "a.py"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7" not in result def test_interleaved_same_tools_not_folded(self): @@ -131,7 +136,7 @@ class TestToolHintFolding: _tc("read_file", {"path": "f.py"}), _tc("grep", {"pattern": "b"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7" not in result @@ -143,7 +148,7 @@ class TestToolHintMultipleCalls: _tc("grep", {"pattern": "TODO"}), _tc("read_file", {"path": "main.py"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert 'grep "TODO"' in result assert "read main.py" in result assert ", " in result @@ -154,27 +159,27 @@ class TestToolHintEdgeCases: def test_known_tool_empty_list_args(self): """C1/G1: Empty list arguments should not crash.""" - result = AgentLoop._tool_hint([_tc("read_file", [])]) + result = _hint([_tc("read_file", [])]) assert result == "read_file" def test_known_tool_none_args(self): """G2: None arguments should not crash.""" - result = AgentLoop._tool_hint([_tc("read_file", None)]) + result = _hint([_tc("read_file", None)]) assert result == "read_file" def test_fallback_empty_list_args(self): """C1: Empty list args in fallback should not crash.""" - result = AgentLoop._tool_hint([_tc("custom_tool", [])]) + result = _hint([_tc("custom_tool", [])]) assert result == "custom_tool" def test_fallback_none_args(self): """G2: None args in fallback should not crash.""" - result = AgentLoop._tool_hint([_tc("custom_tool", None)]) + result = _hint([_tc("custom_tool", None)]) assert result == "custom_tool" def test_list_dir_registered(self): """S2: list_dir should use 'ls' format.""" - result = AgentLoop._tool_hint([_tc("list_dir", {"path": "/tmp"})]) + result = _hint([_tc("list_dir", {"path": "/tmp"})]) assert result == "ls /tmp" @@ -190,7 +195,7 @@ class TestToolHintMixedFolding: _tc("grep", {"pattern": "y"}), _tc("read_file", {"path": "c.py"}), ] - result = AgentLoop._tool_hint(calls) + result = _hint(calls) assert "\u00d7 2" in result # Should have 3 groups: read×2, grep×2, read parts = result.split(", ")