From 43329018f78826770f28674bf0c01643414f65fe Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:50:02 +0000 Subject: [PATCH 01/23] fix(telegram): add group_policy config for Telegram groups Add `group_policy` field to `TelegramConfig` with "open" (default) and "mention" options, consistent with Slack and Matrix channel configs. --- nanobot/config/schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c81..69cf826 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -31,6 +31,7 @@ class TelegramConfig(Base): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" reply_to_message: bool = False # If true, bot replies quote the original message + group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to class FeishuConfig(Base): From 521217a7f50f0a8de46a88e101c8e9bf16abae27 Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:50:36 +0000 Subject: [PATCH 02/23] fix(telegram): enforce group_policy in _on_message When `group_policy` is set to "mention", skip messages in group chats unless the bot is @mentioned or the message is a reply to the bot. Fixes #1380 --- nanobot/channels/telegram.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..3702666 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -341,6 +341,23 @@ class TelegramChannel(BaseChannel): # Store chat_id for replies self._chat_ids[sender_id] = chat_id + # Enforce group_policy: in group chats with "mention" policy, + # only respond when the bot is @mentioned or the message is a reply to the bot. + is_group = message.chat.type != "private" + if is_group and getattr(self.config, "group_policy", "open") == "mention": + bot_username = (await self._app.bot.get_me()).username if self._app else None + mentioned = False + # Check if bot is @mentioned in text + if bot_username and message.text: + mentioned = f"@{bot_username}" in message.text + # Check if the message is a reply to the bot + if not mentioned and message.reply_to_message and message.reply_to_message.from_user: + bot_id = (await self._app.bot.get_me()).id if self._app else None + if bot_id and message.reply_to_message.from_user.id == bot_id: + mentioned = True + if not mentioned: + return + # Build content from text and/or media content_parts = [] media_paths = [] From a7be0b3c9eaf967c0079ab1c1a08be4f2010fc09 Mon Sep 17 00:00:00 2001 From: Yan-ke Guo Date: Tue, 3 Mar 2026 18:14:26 +0800 Subject: [PATCH 03/23] sync missing scripts from upstream openclaw repository --- nanobot/skills/skill-creator/SKILL.md | 3 +- .../skill-creator/scripts/init_skill.py | 378 ++++++++++++++++++ .../skill-creator/scripts/package_skill.py | 139 +++++++ 3 files changed, 519 insertions(+), 1 deletion(-) create mode 100755 nanobot/skills/skill-creator/scripts/init_skill.py create mode 100755 nanobot/skills/skill-creator/scripts/package_skill.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index 9b5eb6f..f4d6e0b 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -349,7 +349,6 @@ scripts/package_skill.py ./dist The packaging script will: 1. **Validate** the skill automatically, checking: - - YAML frontmatter format and required fields - Skill naming conventions and directory structure - Description completeness and quality @@ -357,6 +356,8 @@ The packaging script will: 2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + Security restriction: symlinks are rejected and packaging fails when any symlink is present. + If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. ### Step 6: Iterate diff --git a/nanobot/skills/skill-creator/scripts/init_skill.py b/nanobot/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 0000000..8633fe9 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path [--resources scripts,references,assets] [--examples] + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-new-skill --path skills/public --resources scripts,references + init_skill.py my-api-helper --path skills/private --resources scripts --examples + init_skill.py custom-skill --path /custom/location +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Codex produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Codex produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..aa4de89 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path + +from quick_validate import validate_skill + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"[ERROR] Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"[ERROR] Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"[OK] {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob("*"): + # Security: never follow or package symlinks. + if file_path.is_symlink(): + print(f"[WARN] Skipping symlink: {file_path}") + continue + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == skill_filename.resolve(): + print(f"[WARN] Skipping output archive: {file_path}") + continue + + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n[OK] Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"[ERROR] Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() From d0c647918616f4d5f133f5bf07032d477de3c8f0 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 11:20:50 +0300 Subject: [PATCH 04/23] feat: add LLM retry with exponential backoff for transient errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider.chat() had no retry logic — a transient 429 rate limit, 502 gateway error, or network timeout would permanently fail the entire message. For a system running cron jobs and heartbeats 24/7, even a brief provider blip causes lost tasks. Adds _chat_with_retry() that: - Retries up to 3 times with 1s/2s/4s exponential backoff - Only retries transient errors (429, 5xx, timeout, connection) - Returns immediately on permanent errors (400, 401, etc.) - Falls through to the final attempt if all retries exhaust --- nanobot/agent/loop.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 65a62e5..9819a38 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -159,6 +159,33 @@ class AgentLoop: if hasattr(tool, "set_context"): tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) + _RETRY_DELAYS = (1, 2, 4) # seconds — exponential backoff for transient LLM errors + + async def _chat_with_retry(self, **kwargs: Any) -> Any: + """Call provider.chat() with retry on transient errors (429, 5xx, network).""" + from nanobot.providers.base import LLMResponse + + last_response: LLMResponse | None = None + for attempt, delay in enumerate(self._RETRY_DELAYS): + response = await self.provider.chat(**kwargs) + if response.finish_reason != "error": + return response + # Check if the error looks transient (rate limit, server error, network) + err = (response.content or "").lower() + is_transient = any(kw in err for kw in ( + "429", "rate limit", "500", "502", "503", "504", + "overloaded", "timeout", "connection", "server error", + )) + if not is_transient: + return response # permanent error (400, 401, etc.) — don't retry + last_response = response + logger.warning("LLM transient error (attempt {}/{}), retrying in {}s: {}", + attempt + 1, len(self._RETRY_DELAYS), delay, err[:120]) + await asyncio.sleep(delay) + # All retries exhausted — make one final attempt + response = await self.provider.chat(**kwargs) + return response if response.finish_reason != "error" else (last_response or response) + @staticmethod def _strip_think(text: str | None) -> str | None: """Remove blocks that some models embed in content.""" @@ -191,7 +218,7 @@ class AgentLoop: while iteration < self.max_iterations: iteration += 1 - response = await self.provider.chat( + response = await self._chat_with_retry( messages=messages, tools=self.tools.get_definitions(), model=self.model, From 958c23fb0120d4e24e803e60839f2063d1ddf93e Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Thu, 5 Mar 2026 23:45:20 +0800 Subject: [PATCH 05/23] chore: refine platform policy and memory SKILL docs --- nanobot/agent/context.py | 28 ++++++++++++++++++++++++++++ nanobot/skills/memory/SKILL.md | 15 ++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index df4825f..9254a7f 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -58,6 +58,32 @@ Skills with available="false" need dependencies installed first - you can try in system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" + platform_policy = "" + if system == "Windows": + platform_policy = """## Platform Policy (Windows) +- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell. +- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with: + - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ` + - cmd.exe: `chcp 65001 >NUL & ` +- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins: + - Search text: `findstr /i "keyword" path\\to\\file` + - List files: `dir` + - Show file: `type path\\to\\file` +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability. +""" + elif system == "Darwin": + platform_policy = """## Platform Policy (macOS) +- You are running on macOS. Prefer POSIX tools and UTF-8. +- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +""" + else: + platform_policy = """## Platform Policy (Linux) +- You are running on Linux. Prefer POSIX tools and UTF-8. +- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +""" + return f"""# nanobot 🐈 You are nanobot, a helpful AI assistant. @@ -71,6 +97,8 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md +{platform_policy} + ## nanobot Guidelines - State intent before tool calls, but NEVER predict or claim results before receiving them. - Before modifying a file, read it first. Do not assume files or directories exist. diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 529a02d..865f11f 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -9,15 +9,20 @@ always: true ## Structure - `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context. -- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM]. +- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep-style tools or in-memory filters. Each entry starts with [YYYY-MM-DD HH:MM]. ## Search Past Events -```bash -grep -i "keyword" memory/HISTORY.md -``` +**Recommended approach (cross-platform):** +- Use `read_file` to read `memory/HISTORY.md`, then search in-memory +- This is the most reliable and portable method on all platforms -Use the `exec` tool to run grep. Combine patterns: `grep -iE "meeting|deadline" memory/HISTORY.md` +**Alternative (if you need command-line search):** +- **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md` +- **Windows:** `findstr /i "keyword" memory\HISTORY.md` +- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"` + +Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering. ## When to Update MEMORY.md From 1421ac501c381c253dfca156558b16d6a0f73a64 Mon Sep 17 00:00:00 2001 From: TheAutomatic Date: Sun, 8 Mar 2026 07:04:06 -0700 Subject: [PATCH 06/23] feat(qq): send messages using markdown payload --- nanobot/channels/qq.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 4809fd3..5ac06e3 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -113,16 +113,16 @@ class QQChannel(BaseChannel): if msg_type == "group": await self._client.api.post_group_message( group_openid=msg.chat_id, - msg_type=0, - content=msg.content, + msg_type=2, + markdown={"content": msg.content}, msg_id=msg_id, msg_seq=self._msg_seq, ) else: await self._client.api.post_c2c_message( openid=msg.chat_id, - msg_type=0, - content=msg.content, + msg_type=2, + markdown={"content": msg.content}, msg_id=msg_id, msg_seq=self._msg_seq, ) From ed3b9c16f959d5820298673fe732d899dec9a593 Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sun, 8 Mar 2026 08:05:18 -0600 Subject: [PATCH 07/23] fix: handle CancelledError in MCP tool calls to prevent process crash MCP SDK's anyio cancel scopes can leak CancelledError on timeout or failure paths. Since CancelledError is a BaseException (not Exception), it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(), crashing the agent loop. Now catches CancelledError and returns a graceful error to the LLM, while still re-raising genuine task cancellations from /stop. Also catches general Exception for other MCP failures (connection drops, invalid responses, etc.). Related: #1055 --- nanobot/agent/tools/mcp.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 2cbffd0..cf0a842 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -34,7 +34,7 @@ class MCPToolWrapper(Tool): def parameters(self) -> dict[str, Any]: return self._parameters - async def execute(self, **kwargs: Any) -> str: +async def execute(self, **kwargs: Any) -> str: from mcp import types try: result = await asyncio.wait_for( @@ -44,13 +44,24 @@ class MCPToolWrapper(Tool): except asyncio.TimeoutError: logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) return f"(MCP tool call timed out after {self._tool_timeout}s)" + except asyncio.CancelledError: + # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. + # Re-raise only if our task was externally cancelled (e.g. /stop). + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) + return f"(MCP tool call was cancelled)" + except Exception as exc: + logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc) + return f"(MCP tool call failed: {type(exc).__name__})" parts = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) - return "\n".join(parts) or "(no output)" + return "\n".join(parts) or "(no output) async def connect_mcp_servers( From 7cbb254a8e5140d8393d608a2f41c2885b080ce7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 15:39:40 +0000 Subject: [PATCH 08/23] fix: remove stale IDENTITY bootstrap entry --- nanobot/agent/context.py | 2 +- tests/test_context_prompt_cache.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 27511fa..820baf5 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -16,7 +16,7 @@ from nanobot.utils.helpers import detect_image_mime class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" - BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" def __init__(self, workspace: Path): diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index ce796e2..6eb4b4f 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime as real_datetime +from importlib.resources import files as pkg_files from pathlib import Path import datetime as datetime_module @@ -23,6 +24,13 @@ def _make_workspace(tmp_path: Path) -> Path: return workspace +def test_bootstrap_files_are_backed_by_templates() -> None: + template_dir = pkg_files("nanobot") / "templates" + + for filename in ContextBuilder.BOOTSTRAP_FILES: + assert (template_dir / filename).is_file(), f"missing bootstrap template: {filename}" + + def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None: """System prompt should not change just because wall clock minute changes.""" monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime) From 5eb67facff3b1e063302e5386072f02ca9a528c2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:01:06 +0000 Subject: [PATCH 09/23] Merge branch 'main' into pr-1728 and harden MCP tool cancellation handling --- nanobot/agent/tools/mcp.py | 15 ++++-- tests/test_mcp_tool.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 tests/test_mcp_tool.py diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index cf0a842..400979b 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -34,8 +34,9 @@ class MCPToolWrapper(Tool): def parameters(self) -> dict[str, Any]: return self._parameters -async def execute(self, **kwargs: Any) -> str: + async def execute(self, **kwargs: Any) -> str: from mcp import types + try: result = await asyncio.wait_for( self._session.call_tool(self._original_name, arguments=kwargs), @@ -51,17 +52,23 @@ async def execute(self, **kwargs: Any) -> str: if task is not None and task.cancelling() > 0: raise logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) - return f"(MCP tool call was cancelled)" + return "(MCP tool call was cancelled)" except Exception as exc: - logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc) + logger.exception( + "MCP tool '{}' failed: {}: {}", + self._name, + type(exc).__name__, + exc, + ) return f"(MCP tool call failed: {type(exc).__name__})" + parts = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) - return "\n".join(parts) or "(no output) + return "\n".join(parts) or "(no output)" async def connect_mcp_servers( diff --git a/tests/test_mcp_tool.py b/tests/test_mcp_tool.py new file mode 100644 index 0000000..bf68425 --- /dev/null +++ b/tests/test_mcp_tool.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +import sys +from types import ModuleType, SimpleNamespace + +import pytest + +from nanobot.agent.tools.mcp import MCPToolWrapper + + +class _FakeTextContent: + def __init__(self, text: str) -> None: + self.text = text + + +@pytest.fixture(autouse=True) +def _fake_mcp_module(monkeypatch: pytest.MonkeyPatch) -> None: + mod = ModuleType("mcp") + mod.types = SimpleNamespace(TextContent=_FakeTextContent) + monkeypatch.setitem(sys.modules, "mcp", mod) + + +def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper: + tool_def = SimpleNamespace( + name="demo", + description="demo tool", + inputSchema={"type": "object", "properties": {}}, + ) + return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout) + + +@pytest.mark.asyncio +async def test_execute_returns_text_blocks() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + assert arguments == {"value": 1} + return SimpleNamespace(content=[_FakeTextContent("hello"), 42]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute(value=1) + + assert result == "hello\n42" + + +@pytest.mark.asyncio +async def test_execute_returns_timeout_message() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + await asyncio.sleep(1) + return SimpleNamespace(content=[]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=0.01) + + result = await wrapper.execute() + + assert result == "(MCP tool call timed out after 0.01s)" + + +@pytest.mark.asyncio +async def test_execute_handles_server_cancelled_error() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + raise asyncio.CancelledError() + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute() + + assert result == "(MCP tool call was cancelled)" + + +@pytest.mark.asyncio +async def test_execute_re_raises_external_cancellation() -> None: + started = asyncio.Event() + + async def call_tool(_name: str, arguments: dict) -> object: + started.set() + await asyncio.sleep(60) + return SimpleNamespace(content=[]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10) + task = asyncio.create_task(wrapper.execute()) + await started.wait() + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_execute_handles_generic_exception() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + raise RuntimeError("boom") + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute() + + assert result == "(MCP tool call failed: RuntimeError)" From 4715321319f7282ffe9df99be59898a9782a2440 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:39:37 +0000 Subject: [PATCH 10/23] Merge branch 'main' into pr-1579 and tighten platform guidance --- nanobot/agent/context.py | 25 ++++++------------------- nanobot/skills/memory/SKILL.md | 13 +++++++------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 3dced80..2c648eb 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -62,27 +62,14 @@ Skills with available="false" need dependencies installed first - you can try in platform_policy = "" if system == "Windows": platform_policy = """## Platform Policy (Windows) -- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell. -- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with: - - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ` - - cmd.exe: `chcp 65001 >NUL & ` -- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins: - - Search text: `findstr /i "keyword" path\\to\\file` - - List files: `dir` - - Show file: `type path\\to\\file` -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability. -""" - elif system == "Darwin": - platform_policy = """## Platform Policy (macOS) -- You are running on macOS. Prefer POSIX tools and UTF-8. -- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist. +- Prefer Windows-native commands or file tools when they are more reliable. +- If terminal output is garbled, retry with UTF-8 output enabled. """ else: - platform_policy = """## Platform Policy (Linux) -- You are running on Linux. Prefer POSIX tools and UTF-8. -- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. + platform_policy = """## Platform Policy (POSIX) +- You are running on a POSIX system. Prefer UTF-8 and standard shell tools. +- Use file tools when they are simpler or more reliable than shell commands. """ return f"""# nanobot 🐈 diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 865f11f..3f0a8fc 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -13,16 +13,17 @@ always: true ## Search Past Events -**Recommended approach (cross-platform):** -- Use `read_file` to read `memory/HISTORY.md`, then search in-memory -- This is the most reliable and portable method on all platforms +Choose the search method based on file size: -**Alternative (if you need command-line search):** +- Small `memory/HISTORY.md`: use `read_file`, then search in-memory +- Large or long-lived `memory/HISTORY.md`: use the `exec` tool for targeted search + +Examples: - **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md` - **Windows:** `findstr /i "keyword" memory\HISTORY.md` -- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"` +- **Cross-platform Python:** `python -c "from pathlib import Path; text = Path('memory/HISTORY.md').read_text(encoding='utf-8'); print('\n'.join([l for l in text.splitlines() if 'keyword' in l.lower()][-20:]))"` -Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering. +Prefer targeted command-line search for large history files. ## When to Update MEMORY.md From a0bb4320f48bd4f25e9daf98de7ad2eb9276a42e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:44:47 +0000 Subject: [PATCH 11/23] chore: bump version to 0.1.4.post4 --- 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 4dba5f4..d331109 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post3" +__version__ = "0.1.4.post4" __logo__ = "🐈" diff --git a/pyproject.toml b/pyproject.toml index 41d0fbb..62cf616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post3" +version = "0.1.4.post4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 998021f571a140574af0c29a3c36f51b7ff71e79 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:57:28 +0000 Subject: [PATCH 12/23] docs: refresh install/update guidance and bump v0.1.4.post4 --- README.md | 31 +++++++++++++++++++++++++++---- SECURITY.md | 4 ++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 13971e2..d3401ea 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,29 @@ uv tool install nanobot-ai pip install nanobot-ai ``` +### Update to latest version + +**PyPI / pip** + +```bash +pip install -U nanobot-ai +nanobot --version +``` + +**uv** + +```bash +uv tool upgrade nanobot-ai +nanobot --version +``` + +**Using WhatsApp?** Rebuild the local bridge after upgrading: + +```bash +rm -rf ~/.nanobot/bridge +nanobot channels login +``` + ## 🚀 Quick Start > [!TIP] @@ -374,7 +397,7 @@ pip install nanobot-ai[matrix] | Option | Description | |--------|-------------| -| `allowFrom` | User IDs allowed to interact. Empty = all senders. | +| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. | | `groupPolicy` | `open` (default), `mention`, or `allowlist`. | | `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). | | `allowRoomMentions` | Accept `@room` mentions in mention mode. | @@ -428,7 +451,7 @@ nanobot gateway ``` > WhatsApp bridge updates are not applied automatically for existing installations. -> If you upgrade nanobot and need the latest WhatsApp bridge, run: +> After upgrading nanobot, rebuild the local bridge with: > `rm -rf ~/.nanobot/bridge && nanobot channels login` @@ -900,13 +923,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us > [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. -> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`. +> 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.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | -| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | +| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. | ## 🧩 Multiple Instances diff --git a/SECURITY.md b/SECURITY.md index af4da71..d98adb6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json ``` **Security Notes:** -- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** — set `["*"]` to explicitly allow everyone. +- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone. - Get your Telegram user ID from `@userinfobot` - Use full phone numbers with country code for WhatsApp - Review access logs regularly for unauthorized access attempts @@ -212,7 +212,7 @@ If you suspect a security breach: - Input length limits on HTTP requests ✅ **Authentication** -- Allow-list based access control — in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all) +- Allow-list based access control — in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all) - Failed authentication attempt logging ✅ **Resource Protection** From 4147d0ff9d12f9faaa3aefe5be449b18461588d1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 17:00:09 +0000 Subject: [PATCH 13/23] docs: update v0.1.4.post4 release news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d3401ea..2450b8c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. - **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. From f19cefb1b9b61dcf902afb5666aea80b1c362e46 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 17:00:46 +0000 Subject: [PATCH 14/23] docs: update v0.1.4.post4 release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2450b8c..f169bd7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. +- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. - **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. From 4044b85d4bfa9104b633f3cb408894f0459a0164 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 9 Mar 2026 01:32:10 +0800 Subject: [PATCH 15/23] fix: ensure feishu audio file has .opus extension for Groq Whisper compatibility --- nanobot/channels/feishu.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a637025..0409c32 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -753,8 +753,9 @@ class FeishuChannel(BaseChannel): None, self._download_file_sync, message_id, file_key, msg_type ) if not filename: - ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "") - filename = f"{file_key[:16]}{ext}" + filename = file_key[:16] + if msg_type == "audio" and not filename.endswith(".opus"): + filename = f"{filename}.opus" if data and filename: file_path = media_dir / filename From 620d7896c710748053257695d25c3391aa637dc5 Mon Sep 17 00:00:00 2001 From: ailuntz Date: Tue, 10 Mar 2026 00:14:34 +0800 Subject: [PATCH 16/23] fix(slack): define thread usage when sending messages --- nanobot/channels/slack.py | 2 +- tests/test_slack_channel.py | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/test_slack_channel.py diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index a4e7324..e36c4c9 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -82,6 +82,7 @@ class SlackChannel(BaseChannel): thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads + use_thread = bool(thread_ts and channel_type != "im") thread_ts_param = thread_ts if use_thread else None # Slack rejects empty text payloads. Keep media-only messages media-only, @@ -278,4 +279,3 @@ class SlackChannel(BaseChannel): if parts: rows.append(" · ".join(parts)) return "\n".join(rows) - diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py new file mode 100644 index 0000000..18b96ef --- /dev/null +++ b/tests/test_slack_channel.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.slack import SlackChannel +from nanobot.config.schema import SlackConfig + + +class _FakeAsyncWebClient: + def __init__(self) -> None: + self.chat_post_calls: list[dict[str, object | None]] = [] + self.file_upload_calls: list[dict[str, object | None]] = [] + + async def chat_postMessage( + self, + *, + channel: str, + text: str, + thread_ts: str | None = None, + ) -> None: + self.chat_post_calls.append( + { + "channel": channel, + "text": text, + "thread_ts": thread_ts, + } + ) + + async def files_upload_v2( + self, + *, + channel: str, + file: str, + thread_ts: str | None = None, + ) -> None: + self.file_upload_calls.append( + { + "channel": channel, + "file": file, + "thread_ts": thread_ts, + } + ) + + +@pytest.mark.asyncio +async def test_send_uses_thread_for_channel_messages() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="C123", + content="hello", + media=["/tmp/demo.txt"], + metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}}, + ) + ) + + assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100" + assert len(fake_web.file_upload_calls) == 1 + assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100" + + +@pytest.mark.asyncio +async def test_send_omits_thread_for_dm_messages() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="D123", + content="hello", + media=["/tmp/demo.txt"], + metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}}, + ) + ) + + assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["thread_ts"] is None + assert len(fake_web.file_upload_calls) == 1 + assert fake_web.file_upload_calls[0]["thread_ts"] is None From 9c88e40a616190aca65ce3d3149f4529865ca5d8 Mon Sep 17 00:00:00 2001 From: ailuntz Date: Tue, 10 Mar 2026 00:32:42 +0800 Subject: [PATCH 17/23] fix(cli): respect gateway port from config when --port omitted --- nanobot/cli/commands.py | 5 +++-- tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2c8d6d3..a5906d2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None @app.command() def gateway( - port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -310,8 +310,9 @@ def gateway( logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) + selected_port = port if port is not None else config.gateway.port - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + console.print(f"{__logo__} Starting nanobot gateway on port {selected_port}...") sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) diff --git a/tests/test_commands.py b/tests/test_commands.py index 19c1998..9479dad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -328,6 +328,50 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) assert config.workspace_path == override +def test_gateway_uses_port_from_config_when_cli_port_is_omitted(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.gateway.port = 18791 + + 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: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert "Starting nanobot gateway on port 18791" in result.stdout + + +def test_gateway_cli_port_overrides_config_port(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.gateway.port = 18791 + + 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: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18801"]) + + assert isinstance(result.exception, _StopGateway) + assert "Starting nanobot gateway on port 18801" in result.stdout + + def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) From 28330940d0b2cefbfe740957ee8f51ed9349c24e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Mar 2026 17:18:10 +0000 Subject: [PATCH 18/23] fix(slack): skip thread_ts for direct messages --- nanobot/channels/slack.py | 5 ++--- tests/test_slack_channel.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index e36c4c9..0384d8d 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -81,9 +81,8 @@ class SlackChannel(BaseChannel): slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") - # Only reply in thread for channel/group messages; DMs don't use threads - use_thread = bool(thread_ts and channel_type != "im") - thread_ts_param = thread_ts if use_thread else None + # Slack DMs don't use threads; channel/group replies may keep thread_ts. + thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None # Slack rejects empty text payloads. Keep media-only messages media-only, # but send a single blank message when the bot has no text or files to send. diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 18b96ef..891f86a 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -61,6 +61,7 @@ async def test_send_uses_thread_for_channel_messages() -> None: ) assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["text"] == "hello\n" assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100" assert len(fake_web.file_upload_calls) == 1 assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100" @@ -83,6 +84,7 @@ async def test_send_omits_thread_for_dm_messages() -> None: ) assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["text"] == "hello\n" assert fake_web.chat_post_calls[0]["thread_ts"] is None assert len(fake_web.file_upload_calls) == 1 assert fake_web.file_upload_calls[0]["thread_ts"] is None From 1284c7217ea2c59a5a9e2786c5f550e9fb5ace1b Mon Sep 17 00:00:00 2001 From: Protocol Zero <257158451+Protocol-zero-0@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:12:11 +0000 Subject: [PATCH 19/23] fix(cli): let gateway use config port by default Respect config.gateway.port when --port is omitted, while keeping CLI flags as the highest-precedence override. --- nanobot/cli/commands.py | 3 ++- tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2c8d6d3..37f08b2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None @app.command() def gateway( - port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -310,6 +310,7 @@ def gateway( logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) + port = port if port is not None else config.gateway.port console.print(f"{__logo__} Starting nanobot gateway on port {port}...") sync_workspace_templates(config.workspace_path) diff --git a/tests/test_commands.py b/tests/test_commands.py index 19c1998..5d38942 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -356,3 +356,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat assert isinstance(result.exception, _StopGateway) assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" + + +def test_gateway_uses_configured_port_when_cli_flag_is_missing(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.gateway.port = 18791 + + 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: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert "port 18791" in result.stdout + + +def test_gateway_cli_port_overrides_configured_port(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.gateway.port = 18791 + + 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: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"]) + + assert isinstance(result.exception, _StopGateway) + assert "port 18792" in result.stdout From 4f9857f85f1f8aeddceb019bc0062d3ba7cab032 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 04:34:15 +0000 Subject: [PATCH 20/23] feat(telegram): add configurable group mention policy --- nanobot/channels/telegram.py | 86 ++++++++++++++---- nanobot/config/schema.py | 2 +- tests/test_telegram_channel.py | 156 ++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 18 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 0821b7d..5b294cc 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -179,6 +179,8 @@ class TelegramChannel(BaseChannel): self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} self._message_threads: dict[tuple[str, int], int] = {} + self._bot_user_id: int | None = None + self._bot_username: str | None = None def is_allowed(self, sender_id: str) -> bool: """Preserve Telegram's legacy id|username allowlist matching.""" @@ -242,6 +244,8 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() + self._bot_user_id = getattr(bot_info, "id", None) + self._bot_username = getattr(bot_info, "username", None) logger.info("Telegram bot @{} connected", bot_info.username) try: @@ -462,6 +466,70 @@ class TelegramChannel(BaseChannel): "is_forum": bool(getattr(message.chat, "is_forum", False)), } + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: + """Load bot identity once and reuse it for mention/reply checks.""" + if self._bot_user_id is not None or self._bot_username is not None: + return self._bot_user_id, self._bot_username + if not self._app: + return None, None + bot_info = await self._app.bot.get_me() + self._bot_user_id = getattr(bot_info, "id", None) + self._bot_username = getattr(bot_info, "username", None) + return self._bot_user_id, self._bot_username + + @staticmethod + def _has_mention_entity( + text: str, + entities, + bot_username: str, + bot_id: int | None, + ) -> bool: + """Check Telegram mention entities against the bot username.""" + handle = f"@{bot_username}".lower() + for entity in entities or []: + entity_type = getattr(entity, "type", None) + if entity_type == "text_mention": + user = getattr(entity, "user", None) + if user is not None and bot_id is not None and getattr(user, "id", None) == bot_id: + return True + continue + if entity_type != "mention": + continue + offset = getattr(entity, "offset", None) + length = getattr(entity, "length", None) + if offset is None or length is None: + continue + if text[offset : offset + length].lower() == handle: + return True + return handle in text.lower() + + async def _is_group_message_for_bot(self, message) -> bool: + """Allow group messages when policy is open, @mentioned, or replying to the bot.""" + if message.chat.type == "private" or self.config.group_policy == "open": + return True + + bot_id, bot_username = await self._ensure_bot_identity() + if bot_username: + text = message.text or "" + caption = message.caption or "" + if self._has_mention_entity( + text, + getattr(message, "entities", None), + bot_username, + bot_id, + ): + return True + if self._has_mention_entity( + caption, + getattr(message, "caption_entities", None), + bot_username, + bot_id, + ): + return True + + reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None) + 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.""" message_thread_id = getattr(message, "message_thread_id", None) @@ -501,22 +569,8 @@ class TelegramChannel(BaseChannel): # Store chat_id for replies self._chat_ids[sender_id] = chat_id - # Enforce group_policy: in group chats with "mention" policy, - # only respond when the bot is @mentioned or the message is a reply to the bot. - is_group = message.chat.type != "private" - if is_group and getattr(self.config, "group_policy", "open") == "mention": - bot_username = (await self._app.bot.get_me()).username if self._app else None - mentioned = False - # Check if bot is @mentioned in text - if bot_username and message.text: - mentioned = f"@{bot_username}" in message.text - # Check if the message is a reply to the bot - if not mentioned and message.reply_to_message and message.reply_to_message.from_user: - bot_id = (await self._app.bot.get_me()).id if self._app else None - if bot_id and message.reply_to_message.from_user.id == bot_id: - mentioned = True - if not mentioned: - return + if not await self._is_group_message_for_bot(message): + return # Build content from text and/or media content_parts = [] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 3c5e315..8cfcad6 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -33,7 +33,7 @@ class TelegramConfig(Base): None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" ) reply_to_message: bool = False # If true, bot replies quote the original message - group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to + group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all class FeishuConfig(Base): diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 88c3f54..678512d 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -27,9 +27,11 @@ class _FakeUpdater: class _FakeBot: def __init__(self) -> None: self.sent_messages: list[dict] = [] + self.get_me_calls = 0 async def get_me(self): - return SimpleNamespace(username="nanobot_test") + self.get_me_calls += 1 + return SimpleNamespace(id=999, username="nanobot_test") async def set_my_commands(self, commands) -> None: self.commands = commands @@ -37,6 +39,9 @@ class _FakeBot: async def send_message(self, **kwargs) -> None: self.sent_messages.append(kwargs) + async def send_chat_action(self, **kwargs) -> None: + pass + class _FakeApp: def __init__(self, on_start_polling) -> None: @@ -87,6 +92,35 @@ class _FakeBuilder: return self.app +def _make_telegram_update( + *, + chat_type: str = "group", + text: str | None = None, + caption: str | None = None, + entities=None, + caption_entities=None, + reply_to_message=None, +): + user = SimpleNamespace(id=12345, username="alice", first_name="Alice") + message = SimpleNamespace( + chat=SimpleNamespace(type=chat_type, is_forum=False), + chat_id=-100123, + text=text, + caption=caption, + entities=entities or [], + caption_entities=caption_entities or [], + reply_to_message=reply_to_message, + photo=None, + voice=None, + audio=None, + document=None, + media_group_id=None, + message_thread_id=None, + message_id=1, + ) + return SimpleNamespace(message=message, effective_user=user) + + @pytest.mark.asyncio async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None: config = TelegramConfig( @@ -131,6 +165,10 @@ def test_get_extension_falls_back_to_original_filename() -> None: assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz" +def test_telegram_group_policy_defaults_to_mention() -> None: + assert TelegramConfig().group_policy == "mention" + + def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None: channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus()) @@ -182,3 +220,119 @@ async def test_send_reply_infers_topic_from_message_id_cache() -> None: assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42 assert channel._app.bot.sent_messages[0]["reply_parameters"].message_id == 10 + + +@pytest.mark.asyncio +async def test_group_policy_mention_ignores_unmentioned_group_message() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + await channel._on_message(_make_telegram_update(text="hello everyone"), None) + + assert handled == [] + assert channel._app.bot.get_me_calls == 1 + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_text_mention_and_caches_bot_identity() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + mention = SimpleNamespace(type="mention", offset=0, length=13) + await channel._on_message(_make_telegram_update(text="@nanobot_test hi", entities=[mention]), None) + await channel._on_message(_make_telegram_update(text="@nanobot_test again", entities=[mention]), None) + + assert len(handled) == 2 + assert channel._app.bot.get_me_calls == 1 + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_caption_mention() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + mention = SimpleNamespace(type="mention", offset=0, length=13) + await channel._on_message( + _make_telegram_update(caption="@nanobot_test photo", caption_entities=[mention]), + None, + ) + + assert len(handled) == 1 + assert handled[0]["content"] == "@nanobot_test photo" + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_reply_to_bot() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply = SimpleNamespace(from_user=SimpleNamespace(id=999)) + await channel._on_message(_make_telegram_update(text="reply", reply_to_message=reply), None) + + assert len(handled) == 1 + + +@pytest.mark.asyncio +async def test_group_policy_open_accepts_plain_group_message() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + await channel._on_message(_make_telegram_update(text="hello group"), None) + + assert len(handled) == 1 + assert channel._app.bot.get_me_calls == 0 From b7ecc94c9b85aadc79e0d6598ea42ad7dbaa15f1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 09:16:23 +0000 Subject: [PATCH 21/23] fix(skill-creator): restore validation and align packaging docs --- nanobot/skills/skill-creator/SKILL.md | 10 +- .../skill-creator/scripts/package_skill.py | 77 ++++--- .../skill-creator/scripts/quick_validate.py | 213 ++++++++++++++++++ tests/test_skill_creator_scripts.py | 127 +++++++++++ 4 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 nanobot/skills/skill-creator/scripts/quick_validate.py create mode 100644 tests/test_skill_creator_scripts.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index f4d6e0b..ea53abe 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -268,6 +268,8 @@ Skip this step only if the skill being developed already exists, and iteration o When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. +For `nanobot`, custom skills should live under the active workspace `skills/` directory so they can be discovered automatically at runtime (for example, `/skills/my-skill/SKILL.md`). + Usage: ```bash @@ -277,9 +279,9 @@ scripts/init_skill.py --path [--resources script Examples: ```bash -scripts/init_skill.py my-skill --path skills/public -scripts/init_skill.py my-skill --path skills/public --resources scripts,references -scripts/init_skill.py my-skill --path skills/public --resources scripts --examples +scripts/init_skill.py my-skill --path ./workspace/skills +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts,references +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts --examples ``` The script: @@ -326,7 +328,7 @@ Write the YAML frontmatter with `name` and `description`: - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" -Do not include any other fields in YAML frontmatter. +Keep frontmatter minimal. In `nanobot`, `metadata` and `always` are also supported when needed, but avoid adding extra fields unless they are actually required. ##### Body diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py index aa4de89..48fcbbe 100755 --- a/nanobot/skills/skill-creator/scripts/package_skill.py +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -3,11 +3,11 @@ Skill Packager - Creates a distributable .skill file of a skill folder Usage: - python utils/package_skill.py [output-directory] + python package_skill.py [output-directory] Example: - python utils/package_skill.py skills/public/my-skill - python utils/package_skill.py skills/public/my-skill ./dist + python package_skill.py skills/public/my-skill + python package_skill.py skills/public/my-skill ./dist """ import sys @@ -25,6 +25,14 @@ def _is_within(path: Path, root: Path) -> bool: return False +def _cleanup_partial_archive(skill_filename: Path) -> None: + try: + if skill_filename.exists(): + skill_filename.unlink() + except OSError: + pass + + def package_skill(skill_path, output_dir=None): """ Package a skill folder into a .skill file. @@ -74,49 +82,56 @@ def package_skill(skill_path, output_dir=None): EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + files_to_package = [] + resolved_archive = skill_filename.resolve() + + for file_path in skill_path.rglob("*"): + # Fail closed on symlinks so the packaged contents are explicit and predictable. + if file_path.is_symlink(): + print(f"[ERROR] Symlink not allowed in packaged skill: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == resolved_archive: + print(f"[WARN] Skipping output archive: {file_path}") + continue + files_to_package.append(file_path) + # Create the .skill file (zip format) try: with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: - # Walk through the skill directory - for file_path in skill_path.rglob("*"): - # Security: never follow or package symlinks. - if file_path.is_symlink(): - print(f"[WARN] Skipping symlink: {file_path}") - continue - - rel_parts = file_path.relative_to(skill_path).parts - if any(part in EXCLUDED_DIRS for part in rel_parts): - continue - - if file_path.is_file(): - resolved_file = file_path.resolve() - if not _is_within(resolved_file, skill_path): - print(f"[ERROR] File escapes skill root: {file_path}") - return None - # If output lives under skill_path, avoid writing archive into itself. - if resolved_file == skill_filename.resolve(): - print(f"[WARN] Skipping output archive: {file_path}") - continue - - # Calculate the relative path within the zip. - arcname = Path(skill_name) / file_path.relative_to(skill_path) - zipf.write(file_path, arcname) - print(f" Added: {arcname}") + for file_path in files_to_package: + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") print(f"\n[OK] Successfully packaged skill to: {skill_filename}") return skill_filename except Exception as e: + _cleanup_partial_archive(skill_filename) print(f"[ERROR] Error creating .skill file: {e}") return None def main(): if len(sys.argv) < 2: - print("Usage: python utils/package_skill.py [output-directory]") + print("Usage: python package_skill.py [output-directory]") print("\nExample:") - print(" python utils/package_skill.py skills/public/my-skill") - print(" python utils/package_skill.py skills/public/my-skill ./dist") + print(" python package_skill.py skills/public/my-skill") + print(" python package_skill.py skills/public/my-skill ./dist") sys.exit(1) skill_path = sys.argv[1] diff --git a/nanobot/skills/skill-creator/scripts/quick_validate.py b/nanobot/skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 0000000..03d246d --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Minimal validator for nanobot skill folders. +""" + +import re +import sys +from pathlib import Path +from typing import Optional + +try: + import yaml +except ModuleNotFoundError: + yaml = None + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_FRONTMATTER_KEYS = { + "name", + "description", + "metadata", + "always", + "license", + "allowed-tools", +} +ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"} +PLACEHOLDER_MARKERS = ("[todo", "todo:") + + +def _extract_frontmatter(content: str) -> Optional[str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return "\n".join(lines[1:i]) + return None + + +def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]: + """Fallback parser for simple frontmatter when PyYAML is unavailable.""" + parsed: dict[str, str] = {} + current_key: Optional[str] = None + multiline_key: Optional[str] = None + + for raw_line in frontmatter_text.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + is_indented = raw_line[:1].isspace() + if is_indented: + if current_key is None: + return None + current_value = parsed[current_key] + parsed[current_key] = f"{current_value}\n{stripped}" if current_value else stripped + continue + + if ":" not in stripped: + return None + + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if not key: + return None + + if value in {"|", ">"}: + parsed[key] = "" + current_key = key + multiline_key = key + continue + + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + parsed[key] = value + current_key = key + multiline_key = None + + if multiline_key is not None and multiline_key not in parsed: + return None + return parsed + + +def _load_frontmatter(frontmatter_text: str) -> tuple[Optional[dict], Optional[str]]: + if yaml is not None: + try: + frontmatter = yaml.safe_load(frontmatter_text) + except yaml.YAMLError as exc: + return None, f"Invalid YAML in frontmatter: {exc}" + if not isinstance(frontmatter, dict): + return None, "Frontmatter must be a YAML dictionary" + return frontmatter, None + + frontmatter = _parse_simple_frontmatter(frontmatter_text) + if frontmatter is None: + return None, "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed" + return frontmatter, None + + +def _validate_skill_name(name: str, folder_name: str) -> Optional[str]: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + return ( + f"Name '{name}' should be hyphen-case " + "(lowercase letters, digits, and single hyphens only)" + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + if name != folder_name: + return f"Skill name '{name}' must match directory name '{folder_name}'" + return None + + +def _validate_description(description: str) -> Optional[str]: + trimmed = description.strip() + if not trimmed: + return "Description cannot be empty" + lowered = trimmed.lower() + if any(marker in lowered for marker in PLACEHOLDER_MARKERS): + return "Description still contains TODO placeholder text" + if "<" in trimmed or ">" in trimmed: + return "Description cannot contain angle brackets (< or >)" + if len(trimmed) > 1024: + return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters." + return None + + +def validate_skill(skill_path): + """Validate a skill folder structure and required frontmatter.""" + skill_path = Path(skill_path).resolve() + + if not skill_path.exists(): + return False, f"Skill folder not found: {skill_path}" + if not skill_path.is_dir(): + return False, f"Path is not a directory: {skill_path}" + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + try: + content = skill_md.read_text(encoding="utf-8") + except OSError as exc: + return False, f"Could not read SKILL.md: {exc}" + + frontmatter_text = _extract_frontmatter(content) + if frontmatter_text is None: + return False, "Invalid frontmatter format" + + frontmatter, error = _load_frontmatter(frontmatter_text) + if error: + return False, error + + unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS) + if unexpected_keys: + allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS)) + unexpected = ", ".join(unexpected_keys) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter["name"] + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name_error = _validate_skill_name(name.strip(), skill_path.name) + if name_error: + return False, name_error + + description = frontmatter["description"] + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description_error = _validate_description(description) + if description_error: + return False, description_error + + always = frontmatter.get("always") + if always is not None and not isinstance(always, bool): + return False, f"'always' must be a boolean, got {type(always).__name__}" + + for child in skill_path.iterdir(): + if child.name == "SKILL.md": + continue + if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS: + continue + if child.is_symlink(): + continue + return ( + False, + f"Unexpected file or directory in skill root: {child.name}. " + "Only SKILL.md, scripts/, references/, and assets/ are allowed.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/tests/test_skill_creator_scripts.py b/tests/test_skill_creator_scripts.py new file mode 100644 index 0000000..4207c6f --- /dev/null +++ b/tests/test_skill_creator_scripts.py @@ -0,0 +1,127 @@ +import importlib +import shutil +import sys +import zipfile +from pathlib import Path + + +SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve() +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +init_skill = importlib.import_module("init_skill") +package_skill = importlib.import_module("package_skill") +quick_validate = importlib.import_module("quick_validate") + + +def test_init_skill_creates_expected_files(tmp_path: Path) -> None: + skill_dir = init_skill.init_skill( + "demo-skill", + tmp_path, + ["scripts", "references", "assets"], + include_examples=True, + ) + + assert skill_dir == tmp_path / "demo-skill" + assert (skill_dir / "SKILL.md").exists() + assert (skill_dir / "scripts" / "example.py").exists() + assert (skill_dir / "references" / "api_reference.md").exists() + assert (skill_dir / "assets" / "example_asset.txt").exists() + + +def test_validate_skill_accepts_existing_skill_creator() -> None: + valid, message = quick_validate.validate_skill( + Path("nanobot/skills/skill-creator").resolve() + ) + + assert valid, message + + +def test_validate_skill_rejects_placeholder_description(tmp_path: Path) -> None: + skill_dir = tmp_path / "placeholder-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: placeholder-skill\n" + 'description: "[TODO: fill me in]"\n' + "---\n" + "# Placeholder\n", + encoding="utf-8", + ) + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "TODO placeholder" in message + + +def test_validate_skill_rejects_root_files_outside_allowed_dirs(tmp_path: Path) -> None: + skill_dir = tmp_path / "bad-root-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: bad-root-skill\n" + "description: Valid description\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + (skill_dir / "README.md").write_text("extra\n", encoding="utf-8") + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "Unexpected file or directory in skill root" in message + + +def test_package_skill_creates_archive(tmp_path: Path) -> None: + skill_dir = tmp_path / "package-me" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: package-me\n" + "description: Package this skill.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "helper.py").write_text("print('ok')\n", encoding="utf-8") + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path == (tmp_path / "dist" / "package-me.skill") + assert archive_path.exists() + with zipfile.ZipFile(archive_path, "r") as archive: + names = set(archive.namelist()) + assert "package-me/SKILL.md" in names + assert "package-me/scripts/helper.py" in names + + +def test_package_skill_rejects_symlink(tmp_path: Path) -> None: + skill_dir = tmp_path / "symlink-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: symlink-skill\n" + "description: Reject symlinks during packaging.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + target = tmp_path / "outside.txt" + target.write_text("secret\n", encoding="utf-8") + link = scripts_dir / "outside.txt" + + try: + link.symlink_to(target) + except (OSError, NotImplementedError): + return + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path is None + assert not (tmp_path / "dist" / "symlink-skill.skill").exists() From b0a5435b8720a5968e683ce5aa82a8b16e614452 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 10:10:37 +0000 Subject: [PATCH 22/23] refactor(llm): share transient retry across agent paths --- nanobot/agent/loop.py | 29 +------- nanobot/agent/memory.py | 2 +- nanobot/agent/subagent.py | 2 +- nanobot/heartbeat/service.py | 2 +- nanobot/providers/base.py | 84 ++++++++++++++++++++++ tests/test_heartbeat_service.py | 47 +++++++++++- tests/test_memory_consolidation_types.py | 50 ++++++++++++- tests/test_provider_retry.py | 92 ++++++++++++++++++++++++ 8 files changed, 274 insertions(+), 34 deletions(-) create mode 100644 tests/test_provider_retry.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b67baae..fcbc880 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -159,33 +159,6 @@ class AgentLoop: if hasattr(tool, "set_context"): tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) - _RETRY_DELAYS = (1, 2, 4) # seconds — exponential backoff for transient LLM errors - - async def _chat_with_retry(self, **kwargs: Any) -> Any: - """Call provider.chat() with retry on transient errors (429, 5xx, network).""" - from nanobot.providers.base import LLMResponse - - last_response: LLMResponse | None = None - for attempt, delay in enumerate(self._RETRY_DELAYS): - response = await self.provider.chat(**kwargs) - if response.finish_reason != "error": - return response - # Check if the error looks transient (rate limit, server error, network) - err = (response.content or "").lower() - is_transient = any(kw in err for kw in ( - "429", "rate limit", "500", "502", "503", "504", - "overloaded", "timeout", "connection", "server error", - )) - if not is_transient: - return response # permanent error (400, 401, etc.) — don't retry - last_response = response - logger.warning("LLM transient error (attempt {}/{}), retrying in {}s: {}", - attempt + 1, len(self._RETRY_DELAYS), delay, err[:120]) - await asyncio.sleep(delay) - # All retries exhausted — make one final attempt - response = await self.provider.chat(**kwargs) - return response if response.finish_reason != "error" else (last_response or response) - @staticmethod def _strip_think(text: str | None) -> str | None: """Remove blocks that some models embed in content.""" @@ -218,7 +191,7 @@ class AgentLoop: while iteration < self.max_iterations: iteration += 1 - response = await self._chat_with_retry( + response = await self.provider.chat_with_retry( messages=messages, tools=self.tools.get_definitions(), model=self.model, diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 21fe77d..66efec2 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -111,7 +111,7 @@ class MemoryStore: {chr(10).join(lines)}""" try: - response = await provider.chat( + response = await provider.chat_with_retry( messages=[ {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, {"role": "user", "content": prompt}, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index f2d6ee5..f9eda1f 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -123,7 +123,7 @@ class SubagentManager: while iteration < max_iterations: iteration += 1 - response = await self.provider.chat( + response = await self.provider.chat_with_retry( messages=messages, tools=tools.get_definitions(), model=self.model, diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index e534017..831ae85 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -87,7 +87,7 @@ class HeartbeatService: Returns (action, tasks) where action is 'skip' or 'run'. """ - response = await self.provider.chat( + response = await self.provider.chat_with_retry( messages=[ {"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."}, {"role": "user", "content": ( diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 0f73544..a3b6c47 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -1,9 +1,12 @@ """Base LLM provider interface.""" +import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any +from loguru import logger + @dataclass class ToolCallRequest: @@ -37,6 +40,22 @@ class LLMProvider(ABC): while maintaining a consistent interface. """ + _CHAT_RETRY_DELAYS = (1, 2, 4) + _TRANSIENT_ERROR_MARKERS = ( + "429", + "rate limit", + "500", + "502", + "503", + "504", + "overloaded", + "timeout", + "timed out", + "connection", + "server error", + "temporarily unavailable", + ) + def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key self.api_base = api_base @@ -126,6 +145,71 @@ class LLMProvider(ABC): """ pass + @classmethod + def _is_transient_error(cls, content: str | None) -> bool: + err = (content or "").lower() + return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS) + + async def chat_with_retry( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + ) -> LLMResponse: + """Call chat() with retry on transient provider failures.""" + for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1): + try: + response = await self.chat( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + response = LLMResponse( + content=f"Error calling LLM: {exc}", + finish_reason="error", + ) + + if response.finish_reason != "error": + return response + if not self._is_transient_error(response.content): + return response + + err = (response.content or "").lower() + logger.warning( + "LLM transient error (attempt {}/{}), retrying in {}s: {}", + attempt, + len(self._CHAT_RETRY_DELAYS), + delay, + err[:120], + ) + await asyncio.sleep(delay) + + try: + return await self.chat( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + reasoning_effort=reasoning_effort, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + return LLMResponse( + content=f"Error calling LLM: {exc}", + finish_reason="error", + ) + @abstractmethod def get_default_model(self) -> str: """Get the default model for this provider.""" diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py index c5478af..9ce8912 100644 --- a/tests/test_heartbeat_service.py +++ b/tests/test_heartbeat_service.py @@ -3,18 +3,24 @@ import asyncio import pytest from nanobot.heartbeat.service import HeartbeatService -from nanobot.providers.base import LLMResponse, ToolCallRequest +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest -class DummyProvider: +class DummyProvider(LLMProvider): def __init__(self, responses: list[LLMResponse]): + super().__init__() self._responses = list(responses) + self.calls = 0 async def chat(self, *args, **kwargs) -> LLMResponse: + self.calls += 1 if self._responses: return self._responses.pop(0) return LLMResponse(content="", tool_calls=[]) + def get_default_model(self) -> str: + return "test-model" + @pytest.mark.asyncio async def test_start_is_idempotent(tmp_path) -> None: @@ -115,3 +121,40 @@ async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None: ) assert await service.trigger_now() is None + + +@pytest.mark.asyncio +async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None: + provider = DummyProvider([ + LLMResponse(content="429 rate limit", finish_reason="error"), + LLMResponse( + content="", + tool_calls=[ + ToolCallRequest( + id="hb_1", + name="heartbeat", + arguments={"action": "run", "tasks": "check open tasks"}, + ) + ], + ), + ]) + + delays: list[int] = [] + + async def _fake_sleep(delay: int) -> None: + delays.append(delay) + + monkeypatch.setattr(asyncio, "sleep", _fake_sleep) + + service = HeartbeatService( + workspace=tmp_path, + provider=provider, + model="openai/gpt-4o-mini", + ) + + action, tasks = await service._decide("heartbeat content") + + assert action == "run" + assert tasks == "check open tasks" + assert provider.calls == 2 + assert delays == [1] diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index ff15584..2605bf7 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.memory import MemoryStore -from nanobot.providers.base import LLMResponse, ToolCallRequest +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest def _make_session(message_count: int = 30, memory_window: int = 50): @@ -43,6 +43,22 @@ def _make_tool_response(history_entry, memory_update): ) +class ScriptedProvider(LLMProvider): + def __init__(self, responses: list[LLMResponse]): + super().__init__() + self._responses = list(responses) + self.calls = 0 + + async def chat(self, *args, **kwargs) -> LLMResponse: + self.calls += 1 + if self._responses: + return self._responses.pop(0) + return LLMResponse(content="", tool_calls=[]) + + def get_default_model(self) -> str: + return "test-model" + + class TestMemoryConsolidationTypeHandling: """Test that consolidation handles various argument types correctly.""" @@ -57,6 +73,7 @@ class TestMemoryConsolidationTypeHandling: memory_update="# Memory\nUser likes testing.", ) ) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -77,6 +94,7 @@ class TestMemoryConsolidationTypeHandling: memory_update={"facts": ["User likes testing"], "topics": ["testing"]}, ) ) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -112,6 +130,7 @@ class TestMemoryConsolidationTypeHandling: ], ) provider.chat = AsyncMock(return_value=response) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -127,6 +146,7 @@ class TestMemoryConsolidationTypeHandling: provider.chat = AsyncMock( return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[]) ) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -139,6 +159,7 @@ class TestMemoryConsolidationTypeHandling: """Consolidation should be a no-op when messages < keep_count.""" store = MemoryStore(tmp_path) provider = AsyncMock() + provider.chat_with_retry = provider.chat session = _make_session(message_count=10) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -167,6 +188,7 @@ class TestMemoryConsolidationTypeHandling: ], ) provider.chat = AsyncMock(return_value=response) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -192,6 +214,7 @@ class TestMemoryConsolidationTypeHandling: ], ) provider.chat = AsyncMock(return_value=response) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) @@ -215,8 +238,33 @@ class TestMemoryConsolidationTypeHandling: ], ) provider.chat = AsyncMock(return_value=response) + provider.chat_with_retry = provider.chat session = _make_session(message_count=60) result = await store.consolidate(session, provider, "test-model", memory_window=50) assert result is False + + @pytest.mark.asyncio + async def test_retries_transient_error_then_succeeds(self, tmp_path: Path, monkeypatch) -> None: + store = MemoryStore(tmp_path) + provider = ScriptedProvider([ + LLMResponse(content="503 server error", finish_reason="error"), + _make_tool_response( + history_entry="[2026-01-01] User discussed testing.", + memory_update="# Memory\nUser likes testing.", + ), + ]) + session = _make_session(message_count=60) + delays: list[int] = [] + + async def _fake_sleep(delay: int) -> None: + delays.append(delay) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert provider.calls == 2 + assert delays == [1] diff --git a/tests/test_provider_retry.py b/tests/test_provider_retry.py new file mode 100644 index 0000000..751ecc3 --- /dev/null +++ b/tests/test_provider_retry.py @@ -0,0 +1,92 @@ +import asyncio + +import pytest + +from nanobot.providers.base import LLMProvider, LLMResponse + + +class ScriptedProvider(LLMProvider): + def __init__(self, responses): + super().__init__() + self._responses = list(responses) + self.calls = 0 + + async def chat(self, *args, **kwargs) -> LLMResponse: + self.calls += 1 + response = self._responses.pop(0) + if isinstance(response, BaseException): + raise response + return response + + def get_default_model(self) -> str: + return "test-model" + + +@pytest.mark.asyncio +async def test_chat_with_retry_retries_transient_error_then_succeeds(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse(content="429 rate limit", finish_reason="error"), + LLMResponse(content="ok"), + ]) + delays: list[int] = [] + + async def _fake_sleep(delay: int) -> 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 == "stop" + assert response.content == "ok" + assert provider.calls == 2 + assert delays == [1] + + +@pytest.mark.asyncio +async def test_chat_with_retry_does_not_retry_non_transient_error(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse(content="401 unauthorized", finish_reason="error"), + ]) + delays: list[int] = [] + + async def _fake_sleep(delay: int) -> 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 == "401 unauthorized" + assert provider.calls == 1 + assert delays == [] + + +@pytest.mark.asyncio +async def test_chat_with_retry_returns_final_error_after_retries(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse(content="429 rate limit a", finish_reason="error"), + LLMResponse(content="429 rate limit b", finish_reason="error"), + LLMResponse(content="429 rate limit c", finish_reason="error"), + LLMResponse(content="503 final server error", finish_reason="error"), + ]) + delays: list[int] = [] + + async def _fake_sleep(delay: int) -> 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 == "503 final server error" + assert provider.calls == 4 + assert delays == [1, 2, 4] + + +@pytest.mark.asyncio +async def test_chat_with_retry_preserves_cancelled_error() -> None: + provider = ScriptedProvider([asyncio.CancelledError()]) + + with pytest.raises(asyncio.CancelledError): + await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}]) From 947ed508ad876bdc227c27fd1b008b163ea830b3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 10:13:46 +0000 Subject: [PATCH 23/23] chore: exclude skills from core agent line count --- core_agent_lines.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core_agent_lines.sh b/core_agent_lines.sh index 3f5301a..df32394 100755 --- a/core_agent_lines.sh +++ b/core_agent_lines.sh @@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) printf " %-16s %5s lines\n" "(root)" "$root" echo "" -total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l) +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) echo " Core total: $total lines" echo "" -echo " (excludes: channels/, cli/, providers/)" +echo " (excludes: channels/, cli/, providers/, skills/)"