diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bd3adeb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,83 @@ +name: Build Nanobot OAuth + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +env: + REGISTRY: git.wylab.me + IMAGE_NAME: wylab/nanobot + BUILDKIT_PROGRESS: plain + +jobs: + build: + runs-on: [self-hosted, linux-amd64] + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME || github.actor }} + password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.oauth + provenance: false + platforms: linux/amd64 + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + push: ${{ github.event_name != 'pull_request' }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + + cleanup: + if: github.event_name == 'push' || github.event_name == 'schedule' + runs-on: [self-hosted, linux-amd64] + needs: build + steps: + - name: Delete images older than 24h + env: + TOKEN: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }} + run: | + cutoff=$(date -u -d '24 hours ago' +%s) + page=1 + while true; do + versions=$(curl -sf -H "Authorization: token $TOKEN" \ + "https://${{ env.REGISTRY }}/api/v1/packages/wylab?type=container&q=nanobot&limit=50&page=$page") + count=$(echo "$versions" | jq length) + [ "$count" = "0" ] && break + echo "$versions" | jq -c '.[]' | while read -r pkg; do + ver=$(echo "$pkg" | jq -r '.version') + # Keep latest and buildcache, only delete SHA tags + case "$ver" in latest|buildcache) continue ;; esac + created=$(echo "$pkg" | jq -r '.created_at') + ts=$(date -u -d "$created" +%s 2>/dev/null || echo 0) + if [ "$ts" -lt "$cutoff" ]; then + id=$(echo "$pkg" | jq -r '.id') + echo "Deleting nanobot:$ver (id=$id, created=$created)" + curl -sf -X DELETE -H "Authorization: token $TOKEN" \ + "https://${{ env.REGISTRY }}/api/v1/packages/wylab/container/nanobot/$ver" || true + fi + done + [ "$count" -lt 50 ] && break + page=$((page + 1)) + done diff --git a/.gitignore b/.gitignore index d7b930d..83b7a87 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ docs/ *.pyzz .venv/ venv/ +.worktrees/ __pycache__/ poetry.lock .pytest_cache/ diff --git a/Dockerfile.oauth b/Dockerfile.oauth new file mode 100644 index 0000000..c788781 --- /dev/null +++ b/Dockerfile.oauth @@ -0,0 +1,62 @@ +FROM birdxs/nanobot:latest + +# ── Skill dependencies ────────────────────────────────────────────── + +# APT: ffmpeg (video-frames, whisper), jq, tmux, build-essential (for go) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg jq tmux build-essential procps && rm -rf /var/lib/apt/lists/* + +# gh CLI via GitHub official apt repo +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/* + +# Go toolchain +RUN curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xzf - +ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}" + +# Go tools: blogwatcher, blu (blucli), gifgrep, sonos (sonoscli), wacli, songsee +RUN go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest && \ + go install github.com/steipete/blucli/cmd/blu@latest && \ + go install github.com/steipete/gifgrep/cmd/gifgrep@latest && \ + go install github.com/steipete/sonoscli/cmd/sonos@latest && \ + go install github.com/steipete/wacli/cmd/wacli@latest && \ + go install github.com/steipete/songsee/cmd/songsee@latest + +# Pre-built binaries from GitHub releases +# gogcli (gog) +RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.9.0/gogcli_0.9.0_linux_amd64.tar.gz \ + | tar -xzf - -C /usr/local/bin gog + +# goplaces +RUN curl -fsSL https://github.com/steipete/goplaces/releases/download/v0.2.1/goplaces_0.2.1_linux_amd64.tar.gz \ + | tar -xzf - -C /usr/local/bin goplaces + +# himalaya (email CLI) +RUN curl -fsSL https://github.com/pimalaya/himalaya/releases/download/v1.1.0/himalaya.x86_64-linux.tgz \ + | tar -xzf - -C /usr/local/bin himalaya + +# obsidian-cli (release binary is named notesmd-cli, skill expects obsidian-cli) +RUN curl -fsSL -o /tmp/obsidian.tar.gz https://github.com/yakitrak/obsidian-cli/releases/download/v0.3.0/notesmd-cli_0.3.0_linux_amd64.tar.gz && \ + tar -xzf /tmp/obsidian.tar.gz -C /tmp notesmd-cli && \ + mv /tmp/notesmd-cli /usr/local/bin/obsidian-cli && \ + rm /tmp/obsidian.tar.gz + +# Node tools: oracle, gemini-cli, summarize +RUN npm install -g @steipete/oracle @google/gemini-cli @steipete/summarize + +# Python tools: nano-pdf, openai-whisper +RUN uv tool install nano-pdf && \ + uv tool install openai-whisper +ENV PATH="/root/.local/bin:${PATH}" + +# ── Nanobot source ────────────────────────────────────────────────── + +COPY pyproject.toml README.md LICENSE /app/ +COPY nanobot/ /app/nanobot/ +RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary + +ENTRYPOINT ["nanobot"] +CMD ["gateway"] diff --git a/docs/plans/2026-02-27-native-anthropic-tools-design.md b/docs/plans/2026-02-27-native-anthropic-tools-design.md new file mode 100644 index 0000000..8906dea --- /dev/null +++ b/docs/plans/2026-02-27-native-anthropic-tools-design.md @@ -0,0 +1,265 @@ +# Design: Native Anthropic Tools Integration + +**Goal**: Integrate Anthropic's native trained tools (bash_20250124, text_editor_20250728, computer_20251124) into nanobot to leverage model's trained behaviors instead of custom function tools. + +## Overview + +Anthropic's native tools are version-coupled to model training. Unlike custom function tools (which the model learns via instruction-following at inference time), native tools have their behaviors baked into model weights during training. This provides more reliable tool execution. + +**Key Insight**: The Anthropic API accepts BOTH tool formats in the same request: +- Function tools: `{type: "function", function: {name, description, input_schema}}` +- Native tools: `{type: "bash_20250124", name: "bash"}` (schema-less) + +## Architecture + +### 1. Tool Addition Strategy + +Add three native tool implementations from anthropic-quickstarts reference: +- **BashTool20250124** - persistent bash session (replaces ExecTool) +- **EditTool20250728** - file operations with view/create/str_replace/insert (replaces EditTool, possibly ReadFileTool/WriteFileTool) +- **ComputerTool20251124** - VNC desktop control (new capability) + +Location: `nanobot/agent/tools/anthropic/` (new subpackage) + +Port from reference: +- Base classes: `BaseAnthropicTool`, `ToolResult`, `CLIResult`, `ToolError` +- Tool implementations with trained behaviors intact +- Session management (_BashSession for bash tool) + +### 2. Registry Changes + +Make `ToolRegistry` format-agnostic via duck typing: + +**Current**: Only calls `tool.to_schema()`, expects function format + +**New**: Support both interfaces +```python +def get_definitions(self) -> list[dict[str, Any]]: + definitions = [] + for tool in self._tools.values(): + if hasattr(tool, 'to_params'): # Native Anthropic tool + definitions.append(tool.to_params()) + elif hasattr(tool, 'to_schema'): # Function tool + definitions.append(tool.to_schema()) + else: + raise ValueError(f"Tool {tool.name} has no schema method") + return definitions +``` + +**Execution**: No changes needed - `execute()` already looks up by name and calls the tool. Native tools implement `__call__(**kwargs)` which works with existing dispatch. + +**Result**: Registry becomes thin coordination layer, doesn't enforce specific base class. + +### 3. Tool Implementations + +#### BashTool20250124 +- Maintains persistent bash session via `_BashSession` class +- Sentinel-based output reading for reliable command capture +- Timeout handling (120s default) +- Restart capability +- Returns: `ToolResult(output=..., error=...)` + +#### EditTool20250728 +- Commands: `view`, `create`, `str_replace`, `insert` +- Path validation (absolute paths required) +- `str_replace`: uniqueness checking before replacement +- `insert`: line number validation +- File history tracking for potential undo +- Returns: `CLIResult(output=...)` with formatted snippets + +#### ComputerTool20251124 +- VNC desktop interaction (keyboard, mouse, screenshots) +- Actions: `key`, `type`, `mouse_move`, `left_click`, `right_click`, `double_click`, `screenshot`, etc. +- Screenshot returns `ToolResult(base64_image=...)` +- Coordinate scaling support +- Connects to VNC at 172.17.0.1:5900 (Windows VM from code-server) + +### 4. API Integration + +Update `anthropic_oauth.py._convert_tools_to_anthropic()` to pass through both formats: + +**Current**: Only converts `type: "function"` tools +```python +if tool.get("type") == "function": + # convert to Anthropic format +``` + +**New**: Pass through ALL formats +```python +def _convert_tools_to_anthropic(self, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: + if not tools: + return None + + anthropic_tools = [] + for tool in tools: + if tool.get("type") == "function": + # Convert function tool format + func = tool["function"] + anthropic_tools.append({ + "name": func["name"], + "description": func.get("description", ""), + "input_schema": func.get("parameters", {"type": "object", "properties": {}}) + }) + else: + # Pass through native tool format as-is + # (bash_20250124, text_editor_20250728, computer_20251124) + anthropic_tools.append(tool) + + return anthropic_tools if anthropic_tools else None +``` + +**Distinction**: Based on `type` field +- `type == "function"` → function tool, needs conversion +- `type == "bash_20250124"` (or other native type) → pass through as-is + +### 5. Tool Result Handling + +**Current**: Tools return plain strings + +**New**: Native tools return `ToolResult` objects +```python +@dataclass(kw_only=True, frozen=True) +class ToolResult: + output: str | None = None + error: str | None = None + base64_image: str | None = None + system: str | None = None +``` + +**Agent loop changes** (`loop.py`): Handle both return types +```python +result = await self.tools.execute(tool_name, tool_input) + +if isinstance(result, ToolResult): + # Native tool result - build structured content + tool_result_content = [] + if result.output: + tool_result_content.append({"type": "text", "text": result.output}) + if result.error: + tool_result_content.append({"type": "text", "text": f"Error: {result.error}"}) + if result.base64_image: + # Image handling (see Section 6) + pass + if result.system: + # System messages for next turn + pass +else: + # Legacy string result from function tools + tool_result_content = [{"type": "text", "text": str(result)}] +``` + +### 6. Image Handling Flow + +**Goal**: Both model and user see screenshots from computer tool + +**Implementation**: Track media across tool iteration loop + +```python +# At start of agent turn +media_paths_for_turn: list[str] = [] + +# During tool execution +if isinstance(result, ToolResult) and result.base64_image: + # 1. Save to disk for user + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = media_dir / f"screenshot_{int(time.time())}.png" + screenshot_path.write_bytes(base64.b64decode(result.base64_image)) + media_paths_for_turn.append(str(screenshot_path)) + + # 2. Include in tool_result for model to see + tool_result_content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": result.base64_image + } + }) + +# After final LLM response +await self.bus.publish(OutboundMessage( + channel=inbound.channel, + chat_id=inbound.chat_id, + content=final_response, + media=media_paths_for_turn # Include all screenshots +)) +``` + +**Result**: +- Model sees base64 in tool_result → analyzes and reasons about it +- User receives file via Telegram's media sending (`_send_with_media()`) + +### 7. Version Management & Beta Flags + +**Problem**: Each native tool version requires specific API beta flag + +**Solution**: Add beta flag tracking to native tools + +Each native tool class specifies its required beta flag: +```python +class BashTool20250124(BaseAnthropicTool): + api_type = "bash_20250124" + name = "bash" + beta_flag = "computer-use-2025-11-24" # Required for API +``` + +In `anthropic_oauth.py._make_request()`, collect beta flags: +```python +# Collect unique beta flags from native tools +beta_flags = set() +for tool in tools or []: + if hasattr(tool, 'beta_flag') and tool.beta_flag: + beta_flags.add(tool.beta_flag) + +# Add to API request headers +if beta_flags: + headers["anthropic-beta"] = ",".join(sorted(beta_flags)) +``` + +**Note**: All three tools (bash, text_editor, computer) currently use the same beta flag: `"computer-use-2025-11-24"` as of the 2025-11-24 tool version. + +### 8. Removing Overlapping Tools + +Once native tools are implemented and tested, remove overlapping custom tools: + +**To Remove**: +- `ExecTool` → replaced by `BashTool20250124` (persistent session, better output) +- `EditFileTool` → replaced by `EditTool20250728` (str_replace command) +- Possibly `ReadFileTool`, `WriteFileTool` → `EditTool20250728` has `view` and `create` commands + +**To Keep**: +- `ListDirTool` → no native equivalent +- `WebSearchTool`, `WebFetchTool` → no native equivalent +- `MessageTool`, `SpawnTool`, `WaitForSubagentsTool` → nanobot-specific +- `CronTool` → nanobot-specific + +**Migration Notes**: +- `EditTool20250728` only supports absolute paths (enforced in validation) +- `BashTool20250124` maintains session state across calls (different from ExecTool's one-shot) +- Test native tools thoroughly before removing custom ones + +## Benefits + +1. **Trained Behaviors**: Model knows how to use these tools from training, not instruction-following +2. **Better Reliability**: Persistent bash sessions, validated file operations +3. **New Capabilities**: Desktop interaction via computer tool +4. **Future-Proof**: Easy to add more native tools as Anthropic releases them (just port implementation) +5. **Unified System**: Both function tools and native tools work together in same request + +## Trade-offs + +1. **Code Duplication**: Porting reference implementations means maintaining separate codebase + - Mitigation: Keep close to reference implementation for easier updates +2. **Version Management**: Need to track tool versions and beta flags + - Mitigation: Simple beta_flag attribute on tool classes +3. **Testing Complexity**: Need to test both tool systems + - Mitigation: Gradual rollout, keep custom tools until native tools proven + +## Success Criteria + +1. All three native tools execute successfully +2. Model can use bash, edit, and computer tools in same conversation +3. Screenshots from computer tool visible to both model and user +4. No regression in existing functionality (other tools still work) +5. Performance comparable to custom tools diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index be0ec59..06bb52d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -3,8 +3,6 @@ import base64 import mimetypes import platform -import time -from datetime import datetime from pathlib import Path from typing import Any @@ -13,10 +11,14 @@ from nanobot.agent.skills import SkillsLoader class ContextBuilder: - """Builds the context (system prompt + messages) for the agent.""" + """ + Builds the context (system prompt + messages) for the agent. + + Assembles bootstrap files, memory, skills, and conversation history + into a coherent prompt for the LLM. + """ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] - _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" def __init__(self, workspace: Path): self.workspace = workspace @@ -24,23 +26,43 @@ class ContextBuilder: self.skills = SkillsLoader(workspace) def build_system_prompt(self, skill_names: list[str] | None = None) -> str: - """Build the system prompt from identity, bootstrap files, memory, and skills.""" - parts = [self._get_identity()] - + """ + Build the system prompt from bootstrap files, memory, and skills. + + Args: + skill_names: Optional list of skills to include. + + Returns: + Complete system prompt. + """ + parts = [] + + # Core identity + parts.append(self._get_identity()) + + # Bootstrap files bootstrap = self._load_bootstrap_files() if bootstrap: parts.append(bootstrap) - - memory = self.memory.get_memory_context() - if memory: - parts.append(f"# Memory\n\n{memory}") - + + # Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching) + # MEMORY.md is excluded from system prompt as it changes frequently (consolidator), + # but the agent can still read/grep it via tools. + knowledge_file = self.memory.memory_dir / "KNOWLEDGE.md" + if knowledge_file.exists(): + knowledge = knowledge_file.read_text(encoding="utf-8").strip() + if knowledge: + parts.append(f"# Knowledge\n\n{knowledge}") + + # Skills - progressive loading + # 1. Always-loaded skills: include full content always_skills = self.skills.get_always_skills() if always_skills: always_content = self.skills.load_skills_for_context(always_skills) if always_content: parts.append(f"# Active Skills\n\n{always_content}") - + + # 2. Available skills: only show summary (agent uses read_file to load) skills_summary = self.skills.build_skills_summary() if skills_summary: parts.append(f"""# Skills @@ -49,46 +71,45 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. {skills_summary}""") - + return "\n\n---\n\n".join(parts) def _get_identity(self) -> str: - """Get the core identity section.""" + """Get the core identity section with runtime context.""" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" - - return f"""# nanobot 🐈 -You are nanobot, a helpful AI assistant. + return f"""You have access to tools that allow you to: +- Read, write, and edit files +- Execute shell commands +- Search the web and fetch web pages +- Send messages to users on chat channels +- Spawn subagents for complex background tasks ## Runtime {runtime} ## Workspace Your workspace is at: {workspace_path} -- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) -- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. +- Long-term memory: {workspace_path}/memory/MEMORY.md +- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md -## 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. -- After writing or editing a file, re-read it if accuracy matters. -- If a tool call fails, analyze the error before retrying with a different approach. -- Ask for clarification when the request is ambiguous. +IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. +Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). +For normal conversation, just respond with text - do not call the message tool. -Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.""" +Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. +When remembering something important, write to {workspace_path}/memory/MEMORY.md +To recall past events, grep {workspace_path}/memory/HISTORY.md - @staticmethod - def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: - """Build untrusted runtime metadata block for injection before the user message.""" - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = time.strftime("%Z") or "UTC" - lines = [f"Current Time: {now} ({tz})"] - if channel and chat_id: - lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] - return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) +## Visibility Markers + +Messages marked with [HIDDEN:{{signature}}] were not sent to the user. These markers +are cryptographically signed by the system to track internal reasoning and background +tasks. Do NOT generate [HIDDEN:*] patterns yourself - outputs containing forged +visibility markers will be rejected.""" def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" @@ -111,13 +132,36 @@ Reply directly with text for conversations. Only use the 'message' tool to send channel: str | None = None, chat_id: str | None = None, ) -> list[dict[str, Any]]: - """Build the complete message list for an LLM call.""" - return [ - {"role": "system", "content": self.build_system_prompt(skill_names)}, - *history, - {"role": "user", "content": self._build_runtime_context(channel, chat_id)}, - {"role": "user", "content": self._build_user_content(current_message, media)}, - ] + """ + Build the complete message list for an LLM call. + + Args: + history: Previous conversation messages. + current_message: The new user message. + skill_names: Optional skills to include. + media: Optional list of local file paths for images/media. + channel: Current channel (telegram, feishu, etc.). + chat_id: Current chat/user ID. + + Returns: + List of messages including system prompt. + """ + messages = [] + + # System prompt + system_prompt = self.build_system_prompt(skill_names) + if channel and chat_id: + system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}" + messages.append({"role": "system", "content": system_prompt}) + + # History + messages.extend(history) + + # Current message (with optional image attachments) + user_content = self._build_user_content(current_message, media) + messages.append({"role": "user", "content": user_content}) + + return messages def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: """Build user message content with optional base64-encoded images.""" @@ -138,24 +182,59 @@ Reply directly with text for conversations. Only use the 'message' tool to send return images + [{"type": "text", "text": text}] def add_tool_result( - self, messages: list[dict[str, Any]], - tool_call_id: str, tool_name: str, result: str, + self, + messages: list[dict[str, Any]], + tool_call_id: str, + tool_name: str, + result: str ) -> list[dict[str, Any]]: - """Add a tool result to the message list.""" - messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) + """ + Add a tool result to the message list. + + Args: + messages: Current message list. + tool_call_id: ID of the tool call. + tool_name: Name of the tool. + result: Tool execution result. + + Returns: + Updated message list. + """ + messages.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": result + }) return messages def add_assistant_message( - self, messages: list[dict[str, Any]], + self, + messages: list[dict[str, Any]], content: str | None, tool_calls: list[dict[str, Any]] | None = None, reasoning_content: str | None = None, ) -> list[dict[str, Any]]: - """Add an assistant message to the message list.""" - msg: dict[str, Any] = {"role": "assistant", "content": content} + """ + Add an assistant message to the message list. + + Args: + messages: Current message list. + content: Message content. + tool_calls: Optional tool calls. + reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.). + + Returns: + Updated message list. + """ + msg: dict[str, Any] = {"role": "assistant", "content": content or ""} + if tool_calls: msg["tool_calls"] = tool_calls - if reasoning_content is not None: + + # Thinking models reject history without this + if reasoning_content: msg["reasoning_content"] = reasoning_content + messages.append(msg) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6fe37e9..da14a2e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,7 +5,9 @@ from __future__ import annotations import asyncio import json import re +import time from contextlib import AsyncExitStack +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable @@ -59,6 +61,7 @@ class AgentLoop: exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, restrict_to_workspace: bool = False, + enable_memory_tool: bool = True, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, channels_config: ChannelsConfig | None = None, @@ -77,6 +80,7 @@ class AgentLoop: self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service self.restrict_to_workspace = restrict_to_workspace + self.enable_memory_tool = enable_memory_tool self.context = ContextBuilder(workspace) self.sessions = session_manager or SessionManager(workspace) @@ -103,6 +107,8 @@ class AgentLoop: self._consolidation_locks: dict[str, asyncio.Lock] = {} self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._processing_lock = asyncio.Lock() + self._quota_cache: dict[str, Any] = {} # {model: str, cached_at: float} + self._quota_cache_ttl: float = 300.0 # 5 minutes self._register_default_tools() def _register_default_tools(self) -> None: @@ -122,6 +128,9 @@ class AgentLoop: self.tools.register(SpawnTool(manager=self.subagents)) if self.cron_service: self.tools.register(CronTool(self.cron_service)) + if self.enable_memory_tool: + from nanobot.agent.tools.anthropic import MemoryTool20250818 + self.tools.register(MemoryTool20250818(workspace=self.workspace)) async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" @@ -152,6 +161,83 @@ class AgentLoop: if hasattr(tool, "set_context"): tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) + def _select_model_based_on_quota(self) -> str: + """Select Opus or Sonnet based on rolling weekly quota burn rate.""" + # Check cache first (5-minute TTL) + now = time.time() + if self._quota_cache and (now - self._quota_cache.get("cached_at", 0)) < self._quota_cache_ttl: + return self._quota_cache["model"] + + # Read quota data from rate_limits.json + rate_limits_file = self.workspace / "memory" / "rate_limits.json" + try: + if not rate_limits_file.exists(): + # No quota data yet, default to Sonnet + model = "anthropic/claude-sonnet-4-5" + self._quota_cache = {"model": model, "cached_at": now} + return model + + with open(rate_limits_file, encoding="utf-8") as f: + data = json.load(f) + + # Get current quota usage + weekly_all = data.get("weekly_all_models", {}) + limit = weekly_all.get("limit", 1) + used = weekly_all.get("used", 0) + used_pct = (used / limit) * 100 if limit > 0 else 0 + + # Calculate expected usage based on time elapsed in week + reset_time = datetime.fromisoformat(data.get("weekly_all_models_reset", "")) + now_dt = datetime.now(reset_time.tzinfo) + week_duration = 7 * 24 * 3600 # 1 week in seconds + elapsed = (now_dt - (reset_time - datetime.timedelta(seconds=week_duration))).total_seconds() + elapsed_pct = (elapsed / week_duration) * 100 + + # If we're burning faster than 1.5x expected rate, switch to Sonnet + threshold = elapsed_pct * 1.5 + if used_pct > threshold: + model = "anthropic/claude-sonnet-4-5" + logger.info(f"Quota: {used_pct:.1f}% used, expected {elapsed_pct:.1f}%, threshold {threshold:.1f}% → Sonnet") + else: + model = "anthropic/claude-opus-4-5" + logger.info(f"Quota: {used_pct:.1f}% used, expected {elapsed_pct:.1f}%, threshold {threshold:.1f}% → Opus") + + # Cache the decision + self._quota_cache = {"model": model, "cached_at": now} + return model + + except Exception as e: + logger.error(f"Error checking quota: {e}, defaulting to Sonnet") + return "anthropic/claude-sonnet-4-5" + + def _get_quota_status(self) -> str: + """Return human-readable quota status.""" + rate_limits_file = self.workspace / "memory" / "rate_limits.json" + try: + if not rate_limits_file.exists(): + return "⚠️ No quota data available yet." + + with open(rate_limits_file, encoding="utf-8") as f: + data = json.load(f) + + weekly_all = data.get("weekly_all_models", {}) + limit = weekly_all.get("limit", 1) + used = weekly_all.get("used", 0) + used_pct = (used / limit) * 100 if limit > 0 else 0 + reset_time = data.get("weekly_all_models_reset", "Unknown") + + # Get selected model for current usage + model = self._select_model_based_on_quota() + model_name = "Opus" if "opus" in model.lower() else "Sonnet" + + return f"""📊 Quota Status: +• Usage: {used:,} / {limit:,} tokens ({used_pct:.1f}%) +• Resets: {reset_time} +• Current model: {model_name}""" + + except Exception as e: + return f"⚠️ Error reading quota: {e}" + @staticmethod def _strip_think(text: str | None) -> str | None: """Remove blocks that some models embed in content.""" @@ -183,10 +269,13 @@ class AgentLoop: while iteration < self.max_iterations: iteration += 1 + # Select model based on quota + selected_model = self._select_model_based_on_quota() + response = await self.provider.chat( messages=messages, tools=self.tools.get_definitions(), - model=self.model, + model=selected_model, temperature=self.temperature, max_tokens=self.max_tokens, ) @@ -376,7 +465,10 @@ class AgentLoop: content="New session started.") if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands") + content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/quota — Show quota status\n/help — Show available commands") + if cmd == "/quota": + status = self._get_quota_status() + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status) unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 5b841f3..ea1bedb 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -170,7 +170,7 @@ class SkillsLoader: """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" try: data = json.loads(raw) - return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} + return (data.get("nanobot") or data.get("openclaw") or data.get("clawdbot") or {}) if isinstance(data, dict) else {} except (json.JSONDecodeError, TypeError): return {} diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 337796c..e2bba03 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -15,10 +15,19 @@ from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool +from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.subagent_message import SubagentMessageTool +from nanobot.agent.tools.wait import WaitForSubagentsTool class SubagentManager: - """Manages background subagent execution.""" + """ + Manages background subagent execution. + + Subagents are lightweight agent instances that run in the background + to handle specific tasks. They share the same LLM provider but have + isolated context and a focused system prompt. + """ def __init__( self, @@ -26,8 +35,6 @@ class SubagentManager: workspace: Path, bus: MessageBus, model: str | None = None, - temperature: float = 0.7, - max_tokens: int = 4096, brave_api_key: str | None = None, exec_config: "ExecToolConfig | None" = None, restrict_to_workspace: bool = False, @@ -36,46 +43,58 @@ class SubagentManager: self.provider = provider self.workspace = workspace self.bus = bus - self.model = model or provider.get_default_model() - self.temperature = temperature - self.max_tokens = max_tokens + # Default to Sonnet, not the provider default (Opus). + # Quota switching only affects the main agent's own requests, not SubagentManager. + # Explicit model overrides (e.g. Haiku workers) still take precedence. + self.model = model or "claude-sonnet-4-6" self.brave_api_key = brave_api_key self.exec_config = exec_config or ExecToolConfig() self.restrict_to_workspace = restrict_to_workspace self._running_tasks: dict[str, asyncio.Task[None]] = {} - self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} + self._task_results: dict[str, str] = {} async def spawn( self, task: str, label: str | None = None, + model: str | None = None, origin_channel: str = "cli", origin_chat_id: str = "direct", - session_key: str | None = None, + origin_metadata: dict[str, Any] | None = None, ) -> str: - """Spawn a subagent to execute a task in the background.""" + """ + Spawn a subagent to execute a task in the background. + + Args: + task: The task description for the subagent. + label: Optional human-readable label for the task. + origin_channel: The channel to announce results to. + origin_chat_id: The chat ID to announce results to. + origin_metadata: Optional metadata to propagate to announcement (e.g. suppress_output). + + Returns: + Status message indicating the subagent was started. + """ task_id = str(uuid.uuid4())[:8] display_label = label or task[:30] + ("..." if len(task) > 30 else "") - origin = {"channel": origin_channel, "chat_id": origin_chat_id} + origin = { + "channel": origin_channel, + "chat_id": origin_chat_id, + "metadata": origin_metadata or {}, + } + + # Create background task bg_task = asyncio.create_task( - self._run_subagent(task_id, task, display_label, origin) + self._run_subagent(task_id, task, display_label, origin, model=model) ) self._running_tasks[task_id] = bg_task - if session_key: - self._session_tasks.setdefault(session_key, set()).add(task_id) - - def _cleanup(_: asyncio.Task) -> None: - self._running_tasks.pop(task_id, None) - if session_key and (ids := self._session_tasks.get(session_key)): - ids.discard(task_id) - if not ids: - del self._session_tasks[session_key] - - bg_task.add_done_callback(_cleanup) - logger.info("Spawned subagent [{}]: {}", task_id, display_label) - return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." + # Cleanup when done + bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) + + logger.info(f"Spawned subagent [{task_id}]: {display_label}") + return f"Subagent [{display_label}] started. Task ID: {task_id}" async def _run_subagent( self, @@ -83,27 +102,42 @@ class SubagentManager: task: str, label: str, origin: dict[str, str], + model: str | None = None, ) -> None: """Execute the subagent task and announce the result.""" - logger.info("Subagent [{}] starting task: {}", task_id, label) + logger.info(f"Subagent [{task_id}] starting task: {label}") try: - # Build subagent tools (no message tool, no spawn tool) + # Build subagent tools (no message tool) tools = ToolRegistry() allowed_dir = self.workspace if self.restrict_to_workspace else None - tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(ReadFileTool(allowed_dir=allowed_dir)) + tools.register(WriteFileTool(allowed_dir=allowed_dir)) + tools.register(EditFileTool(allowed_dir=allowed_dir)) + tools.register(ListDirTool(allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, - path_append=self.exec_config.path_append, )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) - + + # Message tool for communicating with user (via main agent) + message_tool = SubagentMessageTool( + bus=self.bus, + origin_channel=origin["channel"], + origin_chat_id=origin["chat_id"], + origin_metadata=origin.get("metadata"), + ) + tools.register(message_tool) + + # Spawn tool for creating child subagents + spawn_tool = SpawnTool(manager=self) + spawn_tool.set_context("subagent", origin["chat_id"], origin.get("metadata")) + tools.register(spawn_tool) + tools.register(WaitForSubagentsTool(manager=self)) + # Build messages with subagent-specific prompt system_prompt = self._build_subagent_prompt(task) messages: list[dict[str, Any]] = [ @@ -112,7 +146,7 @@ class SubagentManager: ] # Run agent loop (limited iterations) - max_iterations = 15 + max_iterations = 50 iteration = 0 final_result: str | None = None @@ -122,9 +156,7 @@ class SubagentManager: response = await self.provider.chat( messages=messages, tools=tools.get_definitions(), - model=self.model, - temperature=self.temperature, - max_tokens=self.max_tokens, + model=model or self.model, ) if response.has_tool_calls: @@ -135,7 +167,7 @@ class SubagentManager: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments, ensure_ascii=False), + "arguments": json.dumps(tc.arguments), }, } for tc in response.tool_calls @@ -148,8 +180,8 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: - args_str = json.dumps(tool_call.arguments, ensure_ascii=False) - logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str) + args_str = json.dumps(tool_call.arguments) + logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", @@ -164,12 +196,12 @@ class SubagentManager: if final_result is None: final_result = "Task completed but no final response was generated." - logger.info("Subagent [{}] completed successfully", task_id) + logger.info(f"Subagent [{task_id}] completed successfully") await self._announce_result(task_id, label, task, final_result, origin, "ok") except Exception as e: error_msg = f"Error: {str(e)}" - logger.error("Subagent [{}] failed: {}", task_id, e) + logger.error(f"Subagent [{task_id}] failed: {e}") await self._announce_result(task_id, label, task, error_msg, origin, "error") async def _announce_result( @@ -183,7 +215,16 @@ class SubagentManager: ) -> None: """Announce the subagent result to the main agent via the message bus.""" status_text = "completed successfully" if status == "ok" else "failed" - + + # ALWAYS store result so wait_for_subagents can find it + self._task_results[task_id] = result + + # Child subagents (spawned by other subagents) don't announce - parent waits for them + if origin["channel"] == "subagent": + logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)") + return + + # Top-level subagents announce via bus to trigger main agent announce_content = f"""[Subagent '{label}' {status_text}] Task: {task} @@ -192,48 +233,43 @@ Result: {result} Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.""" - + # Inject as system message to trigger main agent + # Propagate metadata from origin (e.g. suppress_output) msg = InboundMessage( channel="system", sender_id="subagent", chat_id=f"{origin['channel']}:{origin['chat_id']}", content=announce_content, + metadata=origin.get("metadata", {}), ) - + await self.bus.publish_inbound(msg) - logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) + logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}") def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime - import time as _time - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = _time.strftime("%Z") or "UTC" - return f"""# Subagent -## Current Time -{now} ({tz}) - You are a subagent spawned by the main agent to complete a specific task. ## Rules -1. Stay focused - complete only the assigned task, nothing else -2. Your final response will be reported back to the main agent -3. Do not initiate conversations or take on side tasks -4. Be concise but informative in your findings +1. Run `exec date` as your very first action to get the current date and time +2. Stay focused - complete only the assigned task, nothing else +3. Your final response will be reported back to the main agent +4. Do not initiate conversations or take on side tasks +5. Be concise but informative in your findings ## What You Can Do - Read and write files in the workspace - Execute shell commands - Search the web and fetch web pages +- Send messages to the main agent (via the message tool) +- Spawn child subagents for parallel tasks - Complete the task thoroughly ## What You Cannot Do -- Send messages directly to users (no message tool available) -- Spawn other subagents -- Access the main agent's conversation history +- Access the main agent's conversation history directly ## Workspace Your workspace is at: {self.workspace} @@ -241,15 +277,24 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed When you have completed the task, provide a clear summary of your findings or actions.""" - async def cancel_by_session(self, session_key: str) -> int: - """Cancel all subagents for the given session. Returns count cancelled.""" - tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, []) - if tid in self._running_tasks and not self._running_tasks[tid].done()] - for t in tasks: - t.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - return len(tasks) + async def wait_for(self, task_ids: list[str]) -> str: + """Wait for specified child subagents to complete and return their results.""" + tasks_to_wait = [ + self._running_tasks[tid] + for tid in task_ids + if tid in self._running_tasks + ] + if tasks_to_wait: + await asyncio.gather(*tasks_to_wait, return_exceptions=True) + + results = [] + for tid in task_ids: + result = self._task_results.get(tid) + if result is not None: + results.append(f"[{tid}]:\n{result}") + else: + results.append(f"[{tid}]: No result found (invalid ID or task failed before storing)") + return "\n\n---\n\n".join(results) def get_running_count(self) -> int: """Return the number of currently running subagents.""" diff --git a/nanobot/agent/tools/anthropic/__init__.py b/nanobot/agent/tools/anthropic/__init__.py new file mode 100644 index 0000000..21472f3 --- /dev/null +++ b/nanobot/agent/tools/anthropic/__init__.py @@ -0,0 +1,23 @@ +"""Anthropic native tools implementation.""" + +from nanobot.agent.tools.anthropic.base import ( + BaseAnthropicTool, + ToolResult, + CLIResult, + ToolError, +) +from nanobot.agent.tools.anthropic.bash import BashTool20250124 +from nanobot.agent.tools.anthropic.edit import EditTool20250728 +from nanobot.agent.tools.anthropic.computer import ComputerTool20251124 +from nanobot.agent.tools.anthropic.memory import MemoryTool20250818 + +__all__ = [ + "BaseAnthropicTool", + "ToolResult", + "CLIResult", + "ToolError", + "BashTool20250124", + "EditTool20250728", + "ComputerTool20251124", + "MemoryTool20250818", +] diff --git a/nanobot/agent/tools/anthropic/__pycache__/__init__.cpython-314.pyc b/nanobot/agent/tools/anthropic/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..8bab13d Binary files /dev/null and b/nanobot/agent/tools/anthropic/__pycache__/__init__.cpython-314.pyc differ diff --git a/nanobot/agent/tools/anthropic/__pycache__/base.cpython-314.pyc b/nanobot/agent/tools/anthropic/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..c699ff1 Binary files /dev/null and b/nanobot/agent/tools/anthropic/__pycache__/base.cpython-314.pyc differ diff --git a/nanobot/agent/tools/anthropic/base.py b/nanobot/agent/tools/anthropic/base.py new file mode 100644 index 0000000..c486da4 --- /dev/null +++ b/nanobot/agent/tools/anthropic/base.py @@ -0,0 +1,68 @@ +"""Base classes for Anthropic native tools. + +Ported from anthropic-quickstarts/computer-use-demo. +""" + +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import Any + + +@dataclass(kw_only=True, frozen=True) +class ToolResult: + """Result from tool execution. + + Structured result that can contain text output, errors, images, and system messages. + """ + output: str | None = None + error: str | None = None + base64_image: str | None = None + system: str | None = None + + +@dataclass(kw_only=True, frozen=True) +class CLIResult: + """Result from CLI-style tools (like text editor). + + Similar to ToolResult but simpler for text-only tools. + """ + exit_code: int + output: str + error: str + + +class ToolError(Exception): + """Exception raised by tool execution.""" + pass + + +class BaseAnthropicTool(metaclass=ABCMeta): + """Base class for Anthropic native tools. + + Native tools are version-coupled to model training and don't require schemas. + """ + + api_type: str # e.g., "bash_20250124" + name: str # e.g., "bash" + beta_flag: str | None = None # e.g., "computer-use-2025-11-24" + + @abstractmethod + async def __call__(self, **kwargs: Any) -> ToolResult | CLIResult: + """Execute the tool. + + Args: + **kwargs: Tool-specific parameters + + Returns: + ToolResult or CLIResult with execution output + """ + ... + + @abstractmethod + def to_params(self) -> dict[str, Any]: + """Return tool definition for API. + + Returns: + Dict with type and name (no schema for native tools) + """ + ... diff --git a/nanobot/agent/tools/anthropic/bash.py b/nanobot/agent/tools/anthropic/bash.py new file mode 100644 index 0000000..464917c --- /dev/null +++ b/nanobot/agent/tools/anthropic/bash.py @@ -0,0 +1,174 @@ +"""BashTool20250124 - Persistent bash session with sentinel-based output. + +Anthropic's native bash_20250124 tool with a long-running session. +""" + +import asyncio +import subprocess +import uuid +from typing import Any, Literal + +from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult + + +class _BashSession: + """Manages a persistent bash subprocess with sentinel-based output reading.""" + + def __init__(self): + self.process: subprocess.Popen | None = None + self._start() + + def _start(self): + """Start the bash process.""" + self.process = subprocess.Popen( + ["bash"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + def restart(self): + """Restart the bash session.""" + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self._start() + + async def run_command(self, command: str, timeout: float = 120.0) -> str: + """Run a command in the persistent bash session. + + Uses a unique sentinel to detect command completion. + + Args: + command: Bash command to execute + timeout: Maximum time to wait for command completion (seconds) + + Returns: + Command output (stdout + stderr combined) + + Raises: + asyncio.TimeoutError: If command doesn't complete within timeout + RuntimeError: If bash process has died + """ + if not self.process or self.process.poll() is not None: + raise RuntimeError("Bash process has died") + + # Generate unique sentinel + sentinel = f"<>" + + # Send command + sentinel + full_command = f"{command}\necho '{sentinel}'\n" + self.process.stdin.write(full_command) + self.process.stdin.flush() + + # Read output until sentinel appears + output_lines = [] + start_time = asyncio.get_event_loop().time() + + while True: + # Check timeout + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > timeout: + raise asyncio.TimeoutError( + f"Command timed out after {timeout}s: {command[:50]}..." + ) + + # Read line (non-blocking via asyncio) + try: + line = await asyncio.wait_for( + asyncio.to_thread(self.process.stdout.readline), + timeout=1.0, + ) + except asyncio.TimeoutError: + # No output yet, continue waiting + continue + + if not line: + # EOF - process died + raise RuntimeError("Bash process terminated unexpectedly") + + # Check for sentinel + if sentinel in line: + break + + output_lines.append(line.rstrip("\n")) + + return "\n".join(output_lines) + + def __del__(self): + """Clean up bash process on deletion.""" + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.process.kill() + + +class BashTool20250124(BaseAnthropicTool): + """Anthropic's native bash_20250124 tool with persistent session. + + Executes bash commands in a long-running shell session. Environment + variables and working directory persist across commands. + + Parameters: + command (str, optional): Bash command to execute + restart (bool, optional): Restart the bash session (clears state) + """ + + api_type: Literal["bash_20250124"] = "bash_20250124" + name: Literal["bash"] = "bash" + beta_flag: str = "computer-use-2025-11-24" + + def __init__(self): + self._session = _BashSession() + + async def __call__( + self, + command: str | None = None, + restart: bool = False, + **kwargs: Any, + ) -> ToolResult: + """Execute bash command or restart session. + + Args: + command: Bash command to execute (optional) + restart: Restart the bash session (optional) + **kwargs: Additional arguments (ignored) + + Returns: + ToolResult with command output or error + """ + if restart: + self._session.restart() + return ToolResult(output="Bash session restarted successfully.") + + if not command: + return ToolResult( + error="Either 'command' or 'restart=True' must be provided." + ) + + try: + output = await self._session.run_command(command) + return ToolResult(output=output if output else "(no output)") + except asyncio.TimeoutError as e: + return ToolResult(error=f"Command timed out: {e}") + except Exception as e: + return ToolResult(error=f"{e}") + + def to_params(self) -> dict[str, Any]: + """Convert to Anthropic API tool parameter format. + + Returns: + Tool definition for Anthropic API with bash_20250124 type + """ + return { + "type": self.api_type, + "name": self.name, + } diff --git a/nanobot/agent/tools/anthropic/computer.py b/nanobot/agent/tools/anthropic/computer.py new file mode 100644 index 0000000..f41bc2f --- /dev/null +++ b/nanobot/agent/tools/anthropic/computer.py @@ -0,0 +1,472 @@ +"""Computer control tool for VNC desktop interaction. + +VNC-based implementation of Anthropic's computer_20251124 native tool. + +CRITICAL vncdotool syntax: +- Use :: (double colon) for port numbers: '172.17.0.1::5900' +- Single colon means display number (port = display + 5900) +- vncdotool API is synchronous, wrapped in asyncio.to_thread() +""" + +import asyncio +import base64 +import tempfile +from pathlib import Path +from typing import Literal, Any + +from loguru import logger + +try: + from vncdotool import api as vnc_api +except ImportError: + vnc_api = None + +from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult + + +class ComputerTool20251124(BaseAnthropicTool): + """Computer control via VNC for desktop interaction. + + Supports keyboard input, mouse control, and screenshots. + """ + + api_type: Literal["computer_20251124"] = "computer_20251124" + name: Literal["computer"] = "computer" + beta_flag: str = "computer-use-2025-11-24" + + def __init__( + self, + vnc_host: str = "172.17.0.1", + vnc_port: int = 5900, + vnc_username: str = "deckedmoth", + vnc_password: str = "123", + display_width_px: int = 1024, + display_height_px: int = 768, + ): + """Initialize computer tool. + + Args: + vnc_host: VNC server hostname/IP + vnc_port: VNC server port + vnc_username: VNC username (if required) + vnc_password: VNC password (if required) + display_width_px: Display width for screenshots + display_height_px: Display height for screenshots + """ + if vnc_api is None: + raise ImportError( + "vncdotool is required for computer tool. " + "Install with: pip install vncdotool" + ) + + self.vnc_host = vnc_host + self.vnc_port = vnc_port + self.vnc_username = vnc_username + self.vnc_password = vnc_password + self.display_width_px = display_width_px + self.display_height_px = display_height_px + + def to_params(self): + """Return tool definition for API.""" + return { + "type": self.api_type, + "name": self.name, + "display_width_px": self.display_width_px, + "display_height_px": self.display_height_px, + "enable_zoom": True, + } + + async def __call__( + self, + action: Literal[ + # Basic actions + "key", "type", "mouse_move", "screenshot", "cursor_position", + # Click actions + "left_click", "right_click", "middle_click", "double_click", "triple_click", + # Advanced mouse + "left_mouse_down", "left_mouse_up", "left_click_drag", + # Scroll + "scroll", + # Advanced keyboard + "hold_key", "paste", # paste bypasses keyboard layout issues + # Utility + "wait", + # Zoom (computer_20251124) + "zoom" + ] | None = None, + coordinate: list[int] | None = None, + text: str | None = None, + # Additional parameters for specific actions + start_coordinate: list[int] | None = None, # For left_click_drag + scroll_direction: Literal["up", "down", "left", "right"] | None = None, # For scroll + scroll_amount: int | None = None, # For scroll + duration: float | None = None, # For hold_key, wait + region: list[int] | None = None, # For zoom [x1, y1, x2, y2] + key: str | None = None, # Modifier key for clicks/scroll + **kwargs, + ) -> ToolResult: + """Execute computer control action. + + Args: + action: Action to perform + coordinate: [x, y] coordinates for mouse actions + text: Text to type or key name to press + + Returns: + ToolResult with action result or screenshot + """ + if not action: + return ToolResult(error="No action provided") + + try: + # Connect with correct syntax: double colon (::) for port number + result = await asyncio.to_thread( + self._execute_vnc_action, + action, + coordinate, + text, + start_coordinate, + scroll_direction, + scroll_amount, + duration, + region, + key + ) + return result + except Exception as e: + logger.error(f"Computer tool error: {e}") + return ToolResult(error=str(e)) + + def _execute_vnc_action( + self, + action: str, + coordinate: list[int] | None, + text: str | None, + start_coordinate: list[int] | None, + scroll_direction: str | None, + scroll_amount: int | None, + duration: float | None, + region: list[int] | None, + modifier_key: str | None + ) -> ToolResult: + """Execute VNC action in thread (vncdotool is synchronous). + + CRITICAL: vncdotool syntax requires :: (double colon) for port numbers! + Single colon means display number: 172.17.0.1:5900 = display 5900 (port 11800) + Double colon means port number: 172.17.0.1::5900 = port 5900 + """ + # Connect with DOUBLE colon for port + server = f"{self.vnc_host}::{self.vnc_port}" + client = vnc_api.connect(server, username=self.vnc_username, password=self.vnc_password) + + try: + # Basic actions + if action == "screenshot": + return self._screenshot(client) + elif action == "key": + return self._key(client, text or "") + elif action == "type": + return self._type(client, text or "") + elif action == "mouse_move": + return self._mouse_move(client, coordinate or [0, 0]) + elif action == "cursor_position": + return ToolResult(output="Cursor position tracking not implemented") + + # Click actions + elif action == "left_click": + return self._left_click(client, coordinate, modifier_key) + elif action == "right_click": + return self._right_click(client, coordinate, modifier_key) + elif action == "middle_click": + return self._middle_click(client, coordinate, modifier_key) + elif action == "double_click": + return self._double_click(client, coordinate, modifier_key) + elif action == "triple_click": + return self._triple_click(client, coordinate, modifier_key) + + # Advanced mouse + elif action == "left_mouse_down": + return self._left_mouse_down(client) + elif action == "left_mouse_up": + return self._left_mouse_up(client) + elif action == "left_click_drag": + return self._left_click_drag(client, start_coordinate, coordinate) + + # Scroll + elif action == "scroll": + return self._scroll(client, coordinate, scroll_direction, scroll_amount, modifier_key) + + # Advanced keyboard + elif action == "hold_key": + return self._hold_key(client, text, duration) + elif action == "paste": + return self._paste(client, text) + + # Utility + elif action == "wait": + return self._wait(duration) + + # Zoom + elif action == "zoom": + return self._zoom(client, region) + + else: + return ToolResult(error=f"Unknown action: {action}") + finally: + client.disconnect() + + def _screenshot(self, client) -> ToolResult: + """Capture screenshot. + + captureScreen() requires a file path, can't use BytesIO without format. + Use temp file then read as bytes. + + IMPORTANT: VNC display may be in sleep mode. Wake it up before screenshot. + """ + import time + + # Wake up display (move mouse + press space to wake screensaver) + client.mouseMove(self.display_width_px // 2, self.display_height_px // 2) + time.sleep(0.1) + client.keyPress('space') + time.sleep(0.5) # Wait for display to wake + + # Request framebuffer update + client.refreshScreen() + time.sleep(0.5) # Wait for framebuffer refresh + + # Capture screenshot + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + tmp_path = tmp.name + + client.captureScreen(tmp_path) + png_data = Path(tmp_path).read_bytes() + Path(tmp_path).unlink() # Clean up + + base64_data = base64.b64encode(png_data).decode() + return ToolResult(base64_image=base64_data) + + def _key(self, client, text: str) -> ToolResult: + """Press a key. + + Use lowercase names from KEYMAP: 'esc', 'return', 'tab', etc. + Single characters work directly: 'a', 'b', '1', etc. + """ + client.keyPress(text.lower()) + return ToolResult(output=f"Pressed key: {text}") + + def _type(self, client, text: str) -> ToolResult: + """Type text character by character.""" + for char in text: + client.keyPress(char) + return ToolResult(output=f"Typed: {text}") + + def _mouse_move(self, client, coordinate: list[int]) -> ToolResult: + """Move mouse to coordinate.""" + x, y = coordinate[0], coordinate[1] + client.mouseMove(x, y) + return ToolResult(output=f"Moved mouse to ({x}, {y})") + + def _left_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult: + """Left click at coordinate (or current position).""" + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + if modifier_key: + client.keyDown(modifier_key.lower()) + client.mousePress(1) # 1 = left button + if modifier_key: + client.keyUp(modifier_key.lower()) + return ToolResult(output="Left clicked") + + def _right_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult: + """Right click at coordinate (or current position).""" + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + if modifier_key: + client.keyDown(modifier_key.lower()) + client.mousePress(3) # 3 = right button + if modifier_key: + client.keyUp(modifier_key.lower()) + return ToolResult(output="Right clicked") + + def _middle_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult: + """Middle click at coordinate (or current position).""" + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + if modifier_key: + client.keyDown(modifier_key.lower()) + client.mousePress(2) # 2 = middle button + if modifier_key: + client.keyUp(modifier_key.lower()) + return ToolResult(output="Middle clicked") + + def _double_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult: + """Double click at coordinate (or current position).""" + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + if modifier_key: + client.keyDown(modifier_key.lower()) + client.mousePress(1) + import time + time.sleep(0.01) # 10ms delay between clicks + client.mousePress(1) + if modifier_key: + client.keyUp(modifier_key.lower()) + return ToolResult(output="Double clicked") + + def _triple_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult: + """Triple click at coordinate (or current position).""" + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + if modifier_key: + client.keyDown(modifier_key.lower()) + import time + for _ in range(3): + client.mousePress(1) + time.sleep(0.01) # 10ms delay between clicks + if modifier_key: + client.keyUp(modifier_key.lower()) + return ToolResult(output="Triple clicked") + + def _left_mouse_down(self, client) -> ToolResult: + """Press and hold left mouse button.""" + client.mouseDown(1) + return ToolResult(output="Left mouse button down") + + def _left_mouse_up(self, client) -> ToolResult: + """Release left mouse button.""" + client.mouseUp(1) + return ToolResult(output="Left mouse button up") + + def _left_click_drag(self, client, start_coordinate: list[int] | None, end_coordinate: list[int] | None) -> ToolResult: + """Drag from start to end coordinate.""" + if not start_coordinate or not end_coordinate: + return ToolResult(error="Both start_coordinate and coordinate required for left_click_drag") + + start_x, start_y = start_coordinate[0], start_coordinate[1] + end_x, end_y = end_coordinate[0], end_coordinate[1] + + client.mouseMove(start_x, start_y) + client.mouseDown(1) + client.mouseDrag(end_x, end_y) # vncdotool's mouseDrag method + client.mouseUp(1) + return ToolResult(output=f"Dragged from ({start_x}, {start_y}) to ({end_x}, {end_y})") + + def _scroll( + self, + client, + coordinate: list[int] | None, + scroll_direction: str | None, + scroll_amount: int | None, + modifier_key: str | None + ) -> ToolResult: + """Scroll in specified direction.""" + if not scroll_direction or scroll_direction not in ("up", "down", "left", "right"): + return ToolResult(error=f"scroll_direction must be 'up', 'down', 'left', or 'right'") + + amount = scroll_amount or 5 # Default scroll amount + + # Move to coordinate if specified + if coordinate: + client.mouseMove(coordinate[0], coordinate[1]) + + # VNC scroll buttons: 4=up, 5=down, 6=left, 7=right + scroll_button = {"up": 4, "down": 5, "left": 6, "right": 7}[scroll_direction] + + # Hold modifier key if specified + if modifier_key: + client.keyDown(modifier_key.lower()) + + # Scroll by pressing scroll button multiple times + import time + for _ in range(amount): + client.mousePress(scroll_button) + time.sleep(0.05) # Small delay between scroll events + + if modifier_key: + client.keyUp(modifier_key.lower()) + + return ToolResult(output=f"Scrolled {scroll_direction} {amount} times") + + def _hold_key(self, client, text: str | None, duration: float | None) -> ToolResult: + """Hold a key for specified duration.""" + if not text: + return ToolResult(error="text (key name) required for hold_key") + + hold_duration = duration or 1.0 # Default 1 second + if hold_duration < 0 or hold_duration > 100: + return ToolResult(error="duration must be between 0 and 100 seconds") + + import time + client.keyDown(text.lower()) + time.sleep(hold_duration) + client.keyUp(text.lower()) + + return ToolResult(output=f"Held key '{text}' for {hold_duration}s") + + def _paste(self, client, text: str | None) -> ToolResult: + """Paste text via clipboard (bypasses keyboard layout issues). + + This uses VNC clipboard to send text, avoiding keyboard layout mismatches + where characters like ':' become ';' due to different keyboard mappings. + """ + if not text: + return ToolResult(error="text required for paste") + + # Send text via clipboard and trigger paste + client.paste(text) + return ToolResult(output=f"Pasted via clipboard: {text[:50]}{'...' if len(text) > 50 else ''}") + + def _wait(self, duration: float | None) -> ToolResult: + """Wait for specified duration.""" + wait_duration = duration or 1.0 + if wait_duration < 0 or wait_duration > 100: + return ToolResult(error="duration must be between 0 and 100 seconds") + + import time + time.sleep(wait_duration) + return ToolResult(output=f"Waited {wait_duration}s") + + def _zoom(self, client, region: list[int] | None) -> ToolResult: + """Zoom into specified region and capture screenshot. + + Region format: [x1, y1, x2, y2] - top-left and bottom-right corners. + """ + if not region or len(region) != 4: + return ToolResult(error="region must be [x1, y1, x2, y2]") + + # Take full screenshot first + import time + from PIL import Image + + # Wake up display + client.mouseMove(self.display_width_px // 2, self.display_height_px // 2) + time.sleep(0.1) + client.keyPress('space') + time.sleep(0.5) + client.refreshScreen() + time.sleep(0.5) + + # Capture screenshot + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + tmp_path = tmp.name + + client.captureScreen(tmp_path) + + # Crop to region + img = Image.open(tmp_path) + x1, y1, x2, y2 = region + cropped = img.crop((x1, y1, x2, y2)) + + # Save cropped image + cropped_path = tmp_path.replace('.png', '_cropped.png') + cropped.save(cropped_path) + + # Read and encode + png_data = Path(cropped_path).read_bytes() + Path(tmp_path).unlink() # Clean up original + Path(cropped_path).unlink() # Clean up cropped + + base64_data = base64.b64encode(png_data).decode() + return ToolResult(base64_image=base64_data) + diff --git a/nanobot/agent/tools/anthropic/edit.py b/nanobot/agent/tools/anthropic/edit.py new file mode 100644 index 0000000..6451e2d --- /dev/null +++ b/nanobot/agent/tools/anthropic/edit.py @@ -0,0 +1,257 @@ +""" +EditTool20250728 - File editor with view/create/str_replace/insert commands. + +Anthropic's native trained tool for file editing operations. +""" + +from pathlib import Path +from typing import Any, Literal + +from .base import BaseAnthropicTool, CLIResult + + +class EditTool20250728(BaseAnthropicTool): + """ + File editor supporting view, create, str_replace, and insert operations. + + Trained by Anthropic, this tool provides comprehensive file editing + capabilities with strict safety checks. + """ + + api_type: Literal["text_editor_20250728"] = "text_editor_20250728" + name: Literal["str_replace_based_edit_tool"] = "str_replace_based_edit_tool" + beta_flag: str = "computer-use-2025-11-24" + + async def __call__( + self, + command: Literal["view", "create", "str_replace", "insert"], + path: str, + file_text: str | None = None, + old_str: str | None = None, + new_str: str | None = None, + insert_line: int | None = None, + view_range: list[int] | None = None, + **kwargs: Any, + ) -> CLIResult: + """ + Execute a file editing command. + + Args: + command: The operation to perform + path: Absolute path to the file + file_text: Full file content (for create) + old_str: String to replace (for str_replace) + new_str: Replacement string (for str_replace/insert) + insert_line: Line number to insert at (for insert) + view_range: [start, end] line range (for view) + **kwargs: Additional arguments (ignored) + + Returns: + CLIResult with exit code, output, and error + """ + # Validate absolute path + file_path = Path(path) + if not file_path.is_absolute(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: path must be absolute, got: {path}" + ) + + try: + if command == "view": + return await self._view(file_path, view_range) + elif command == "create": + return await self._create(file_path, file_text) + elif command == "str_replace": + return await self._str_replace(file_path, old_str, new_str) + elif command == "insert": + return await self._insert(file_path, insert_line, new_str) + else: + return CLIResult( + exit_code=1, + output="", + error=f"Error: unknown command: {command}" + ) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error: {str(e)}" + ) + + async def _view(self, path: Path, view_range: list[int] | None) -> CLIResult: + """View file contents with line numbers.""" + if not path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: file not found: {path}" + ) + + content = path.read_text() + lines = content.splitlines(keepends=True) + + # Apply view range if specified + if view_range: + start, end = view_range + lines = lines[start - 1:end] + start_num = start + else: + start_num = 1 + + # Format with line numbers + formatted_lines = [ + f"{start_num + i}|{line.rstrip()}" + for i, line in enumerate(lines) + ] + + return CLIResult( + exit_code=0, + output="\n".join(formatted_lines), + error="" + ) + + async def _create(self, path: Path, file_text: str | None) -> CLIResult: + """Create a new file with the given content.""" + if file_text is None: + return CLIResult( + exit_code=1, + output="", + error="Error: file_text is required for create command" + ) + + if path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: file already exists: {path}" + ) + + # Create parent directories if needed + path.parent.mkdir(parents=True, exist_ok=True) + + # Write the file + path.write_text(file_text) + + return CLIResult( + exit_code=0, + output=f"File created: {path}", + error="" + ) + + async def _str_replace( + self, + path: Path, + old_str: str | None, + new_str: str | None + ) -> CLIResult: + """Replace a unique occurrence of old_str with new_str.""" + if old_str is None: + return CLIResult( + exit_code=1, + output="", + error="Error: old_str is required for str_replace command" + ) + + if new_str is None: + return CLIResult( + exit_code=1, + output="", + error="Error: new_str is required for str_replace command" + ) + + if not path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: file not found: {path}" + ) + + content = path.read_text() + + # Check for unique match + count = content.count(old_str) + if count == 0: + return CLIResult( + exit_code=1, + output="", + error=f"Error: old_str not found in file: {old_str!r}" + ) + elif count > 1: + return CLIResult( + exit_code=1, + output="", + error=f"Error: old_str must match exactly once, found {count} matches" + ) + + # Perform replacement + new_content = content.replace(old_str, new_str) + path.write_text(new_content) + + return CLIResult( + exit_code=0, + output=f"Replaced 1 occurrence in: {path}", + error="" + ) + + async def _insert( + self, + path: Path, + insert_line: int | None, + new_str: str | None + ) -> CLIResult: + """Insert new_str at the specified line number.""" + if insert_line is None: + return CLIResult( + exit_code=1, + output="", + error="Error: insert_line is required for insert command" + ) + + if new_str is None: + return CLIResult( + exit_code=1, + output="", + error="Error: new_str is required for insert command" + ) + + if not path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: file not found: {path}" + ) + + content = path.read_text() + lines = content.splitlines(keepends=True) + + # Validate line number + if insert_line < 0 or insert_line > len(lines): + return CLIResult( + exit_code=1, + output="", + error=f"Error: insert_line {insert_line} out of range [0, {len(lines)}]" + ) + + # Insert the new string + lines.insert(insert_line, new_str) + new_content = "".join(lines) + path.write_text(new_content) + + return CLIResult( + exit_code=0, + output=f"Inserted text at line {insert_line} in: {path}", + error="" + ) + + def to_params(self) -> dict[str, Any]: + """Convert to Anthropic API tool parameter format. + + Returns: + Tool definition for Anthropic API with text_editor_20250728 type + """ + return { + "type": self.api_type, + "name": self.name, + } diff --git a/nanobot/agent/tools/anthropic/memory.py b/nanobot/agent/tools/anthropic/memory.py new file mode 100644 index 0000000..68275c3 --- /dev/null +++ b/nanobot/agent/tools/anthropic/memory.py @@ -0,0 +1,592 @@ +"""MemoryTool20250818 - Anthropic's native memory tool. + +Enables Claude to create, read, update, and delete files in a persistent +/memories directory across conversations. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, CLIResult + + +class MemoryTool20250818(BaseAnthropicTool): + """Anthropic's native memory_20250818 tool. + + Client-side tool for persistent memory storage across conversations. + All operations are restricted to the /memories directory. + + Commands: + - view: Show directory contents or file contents with line numbers + - create: Create a new file with content + - str_replace: Replace unique text occurrence in a file + - insert: Insert text at a specific line number + - delete: Delete a file or directory + - rename: Rename or move a file/directory + """ + + api_type: Literal["memory_20250818"] = "memory_20250818" + name: Literal["memory"] = "memory" + beta_flag: str = "context-management-2025-06-27" + + def __init__(self, workspace: Path): + """Initialize Memory tool. + + Args: + workspace: Root workspace directory + """ + self.workspace = workspace + self.memories_dir = workspace / "memories" + self.memories_dir.mkdir(parents=True, exist_ok=True) + + def _validate_memory_path(self, path: str) -> Path: + """Validate and resolve path to prevent directory traversal. + + Args: + path: Path string starting with /memories + + Returns: + Validated absolute Path within memories directory + + Raises: + ValueError: If path is invalid or escapes /memories directory + """ + # Reject paths not starting with /memories + if not path.startswith("/memories"): + raise ValueError(f"Path must start with /memories, got: {path}") + + # Resolve to absolute path within workspace + # lstrip("/") removes leading slash: "/memories/file.txt" -> "memories/file.txt" + relative_path = path.lstrip("/") + full_path = (self.workspace / relative_path).resolve() + + # Verify resolved path is within memories directory + memories_dir_resolved = self.memories_dir.resolve() + try: + full_path.relative_to(memories_dir_resolved) + except ValueError: + raise ValueError(f"Path escapes /memories directory: {path}") + + return full_path + + async def __call__( + self, + command: Literal["view", "create", "str_replace", "insert", "delete", "rename"], + path: str | None = None, + old_path: str | None = None, + new_path: str | None = None, + file_text: str | None = None, + old_str: str | None = None, + new_str: str | None = None, + insert_line: int | None = None, + insert_text: str | None = None, + view_range: list[int] | None = None, + **kwargs: Any, + ) -> CLIResult: + """Execute memory command. + + Args: + command: Command to execute + path: File/directory path (for view/create/str_replace/insert/delete) + old_path: Source path (for rename) + new_path: Destination path (for rename) + file_text: File content (for create) + old_str: Text to find (for str_replace) + new_str: Replacement text (for str_replace) + insert_line: Line number to insert at (for insert) + insert_text: Text to insert (for insert) + view_range: [start_line, end_line] for view + **kwargs: Additional arguments (ignored) + + Returns: + CLIResult with command output or error + """ + try: + if command == "view": + return await self._view(path, view_range) + elif command == "create": + return await self._create(path, file_text) + elif command == "str_replace": + return await self._str_replace(path, old_str, new_str) + elif command == "insert": + return await self._insert(path, insert_line, insert_text) + elif command == "delete": + return await self._delete(path) + elif command == "rename": + return await self._rename(old_path, new_path) + else: + return CLIResult( + exit_code=1, + output="", + error=f"Unknown command: {command}" + ) + except ValueError as e: + # Path security error + return CLIResult( + exit_code=1, + output="", + error=f"Error: {e}" + ) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error: {e}" + ) + + async def _view( + self, + path: str | None, + view_range: list[int] | None = None, + ) -> CLIResult: + """View directory listing or file contents. + + Args: + path: Path to view + view_range: Optional [start_line, end_line] for file viewing (1-indexed) + + Returns: + CLIResult with directory listing or file contents + """ + if path is None: + return CLIResult( + exit_code=1, + output="", + error="Error: path is required for view command" + ) + + path_str = path # Keep original for error messages + validated_path = self._validate_memory_path(path) + + # Directory listing + if validated_path.is_dir(): + return await self._view_directory(validated_path, path_str) + + # File viewing + if not validated_path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"The path {path_str} does not exist. Please provide a valid path." + ) + + # Read file + try: + content = validated_path.read_text() + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error reading file: {e}" + ) + + lines = content.splitlines(keepends=True) + + # Check line limit + if len(lines) > 999_999: + return CLIResult( + exit_code=1, + output="", + error=f"File {path_str} exceeds maximum line limit of 999,999 lines." + ) + + # Apply view_range if specified + if view_range: + start, end = view_range + # Convert to 0-indexed, clamp to valid range + start_idx = max(0, start - 1) + end_idx = min(len(lines), end) + lines_to_show = lines[start_idx:end_idx] + start_num = start + else: + lines_to_show = lines + start_num = 1 + + # Format with line numbers (6 chars, right-aligned, tab-separated) + formatted_lines = [] + for i, line in enumerate(lines_to_show): + line_num = start_num + i + # Remove trailing newline for display + line_content = line.rstrip("\n") + formatted_lines.append(f"{line_num:6d}\t{line_content}") + + output = f"Here's the content of {path_str} with line numbers:\n" + output += "\n".join(formatted_lines) + + return CLIResult( + exit_code=0, + output=output, + error="" + ) + + async def _view_directory(self, path: Path, path_str: str) -> CLIResult: + """View directory listing up to 2 levels deep. + + Args: + path: Validated Path object + path_str: Original path string for display + + Returns: + CLIResult with directory listing + """ + import os + + def format_size(size_bytes: int) -> str: + """Convert bytes to human-readable format.""" + for unit in ['B', 'K', 'M', 'G', 'T']: + if size_bytes < 1024: + return f"{size_bytes:.1f}{unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f}P" + + lines = [] + header = f"Here're the files and directories up to 2 levels deep in {path_str}, excluding hidden items and node_modules:" + lines.append(header) + + # Walk directory tree (max depth 2) + base_depth = str(path).count(os.sep) + + for root, dirs, files in os.walk(path): + # Calculate current depth + current_depth = str(root).count(os.sep) - base_depth + + # Filter out hidden items and node_modules at this level + dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules'] + + # Stop if we've gone too deep + if current_depth >= 2: + dirs.clear() # Don't recurse further + continue + + # Get size and add directory entry + root_path = Path(root) + try: + # Directory size (sum of all files within, or 4K default) + dir_size = sum(f.stat().st_size for f in root_path.rglob('*') if f.is_file()) + if dir_size == 0: + dir_size = 4096 # Default directory size + size_str = format_size(dir_size) + + # Convert absolute path to /memories/... format + relative = root_path.relative_to(self.workspace) + display_path = "/" + str(relative).replace(os.sep, "/") + + lines.append(f"{size_str}\t{display_path}") + except Exception: + pass + + # Add file entries at this level + for filename in sorted(files): + if filename.startswith('.'): + continue # Skip hidden files + + file_path = root_path / filename + try: + file_size = file_path.stat().st_size + size_str = format_size(file_size) + + # Convert to /memories/... format + relative = file_path.relative_to(self.workspace) + display_path = "/" + str(relative).replace(os.sep, "/") + + lines.append(f"{size_str}\t{display_path}") + except Exception: + pass + + return CLIResult( + exit_code=0, + output="\n".join(lines), + error="" + ) + + async def _create( + self, + path: str | None, + file_text: str | None, + ) -> CLIResult: + """Create a new file with content. + + Args: + path: File path to create + file_text: Content to write + + Returns: + CLIResult with success message or error + """ + if path is None: + return CLIResult( + exit_code=1, + output="", + error="Error: path is required for create command" + ) + + if file_text is None: + return CLIResult( + exit_code=1, + output="", + error="Error: file_text is required for create command" + ) + + path_str = path # Keep original for error messages + validated_path = self._validate_memory_path(path) + + # Check if file already exists + if validated_path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: File {path_str} already exists" + ) + + # Create parent directories if needed + try: + validated_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error creating parent directories: {e}" + ) + + # Write file + try: + validated_path.write_text(file_text) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error writing file: {e}" + ) + + return CLIResult( + exit_code=0, + output=f"File created successfully at: {path_str}", + error="" + ) + + async def _str_replace( + self, + path: str | None, + old_str: str | None, + new_str: str | None, + ) -> CLIResult: + """Replace unique occurrence of old_str with new_str.""" + if path is None or old_str is None or new_str is None: + return CLIResult( + exit_code=1, + output="", + error="Error: old_str and new_str are required for str_replace command" + ) + + path_str = path + validated_path = self._validate_memory_path(path) + + if not validated_path.exists() or validated_path.is_dir(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: The path {path_str} does not exist. Please provide a valid path." + ) + + content = validated_path.read_text() + count = content.count(old_str) + + if count == 0: + return CLIResult( + exit_code=1, + output="", + error=f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path_str}." + ) + elif count > 1: + lines = content.splitlines() + line_nums = [i + 1 for i, line in enumerate(lines) if old_str in line] + return CLIResult( + exit_code=1, + output="", + error=f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {line_nums}. Please ensure it is unique" + ) + + new_content = content.replace(old_str, new_str, 1) + validated_path.write_text(new_content) + + return CLIResult( + exit_code=0, + output="The memory file has been edited.", + error="" + ) + + async def _insert( + self, path: str | None, insert_line: int | None, insert_text: str | None + ) -> CLIResult: + """Insert text at a specific line number. + + Args: + path: File path to modify + insert_line: Line number to insert at (0 = beginning) + insert_text: Text to insert + + Returns: + CLIResult with success message or error + """ + if path is None or insert_line is None or insert_text is None: + return CLIResult( + exit_code=1, + output="", + error="Error: path, insert_line, and insert_text are required for insert command" + ) + + path_str = path + validated_path = self._validate_memory_path(path) + + if not validated_path.exists() or validated_path.is_dir(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: The path {path_str} does not exist. Please provide a valid path." + ) + + # Read current content + content = validated_path.read_text() + lines = content.splitlines(keepends=True) + + # Validate insert_line + if insert_line < 0 or insert_line > len(lines): + return CLIResult( + exit_code=1, + output="", + error=f"Invalid `insert_line` parameter: {insert_line}. It should be within 0 to {len(lines)}" + ) + + # Insert text at specified line + lines.insert(insert_line, insert_text) + new_content = "".join(lines) + validated_path.write_text(new_content) + + return CLIResult( + exit_code=0, + output=f"The file {path_str} has been edited.", + error="" + ) + + async def _delete(self, path: str | None) -> CLIResult: + """Delete a file or directory. + + Args: + path: Path to delete + + Returns: + CLIResult with success message or error + """ + if path is None: + return CLIResult( + exit_code=1, + output="", + error="Error: path is required for delete command" + ) + + path_str = path + validated_path = self._validate_memory_path(path) + + if not validated_path.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: The path {path_str} does not exist. Please provide a valid path." + ) + + # Delete file or directory + try: + if validated_path.is_dir(): + import shutil + shutil.rmtree(validated_path) + else: + validated_path.unlink() + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error deleting {path_str}: {e}" + ) + + return CLIResult( + exit_code=0, + output=f"Successfully deleted {path_str}", + error="" + ) + + async def _rename(self, old_path: str | None, new_path: str | None) -> CLIResult: + """Rename or move a file or directory. + + Args: + old_path: Source path + new_path: Destination path + + Returns: + CLIResult with success message or error + """ + if old_path is None or new_path is None: + return CLIResult( + exit_code=1, + output="", + error="Error: old_path and new_path are required for rename command" + ) + + old_path_str = old_path + new_path_str = new_path + validated_old = self._validate_memory_path(old_path) + validated_new = self._validate_memory_path(new_path) + + # Check if source exists + if not validated_old.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: The path {old_path_str} does not exist. Please provide a valid path." + ) + + # Check if destination already exists + if validated_new.exists(): + return CLIResult( + exit_code=1, + output="", + error=f"Error: The destination {new_path_str} already exists. Please provide a different destination." + ) + + # Create parent directories if needed + try: + validated_new.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error creating parent directories: {e}" + ) + + # Rename/move + try: + validated_old.rename(validated_new) + except Exception as e: + return CLIResult( + exit_code=1, + output="", + error=f"Error renaming {old_path_str}: {e}" + ) + + return CLIResult( + exit_code=0, + output=f"Successfully renamed {old_path_str} to {new_path_str}", + error="" + ) + + def to_params(self) -> dict[str, Any]: + """Convert to Anthropic API tool parameter format. + + Returns: + Tool definition for Anthropic API + """ + return { + "type": self.api_type, + "name": self.name, + } diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 35e519a..6e40923 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,49 +1,44 @@ """Message tool for sending messages to users.""" -from typing import Any, Awaitable, Callable +from typing import Any, Callable, Awaitable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage +from nanobot.session import SessionManager class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, + sessions: SessionManager | None = None, default_channel: str = "", - default_chat_id: str = "", - default_message_id: str | None = None, + default_chat_id: str = "" ): self._send_callback = send_callback + self._sessions = sessions self._default_channel = default_channel self._default_chat_id = default_chat_id - self._default_message_id = default_message_id - self._sent_in_turn: bool = False - - def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: + + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - self._default_message_id = message_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - - def start_turn(self) -> None: - """Reset per-turn send tracking.""" - self._sent_in_turn = False - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: return "Send a message to the user. Use this when you want to communicate something." - + @property def parameters(self) -> dict[str, Any]: return { @@ -53,6 +48,11 @@ class MessageTool(Tool): "type": "string", "description": "The message content to send" }, + "media": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: list of media file paths or URLs to attach" + }, "channel": { "type": "string", "description": "Optional: target channel (telegram, discord, etc.)" @@ -60,28 +60,21 @@ class MessageTool(Tool): "chat_id": { "type": "string", "description": "Optional: target chat/user ID" - }, - "media": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)" } }, "required": ["content"] } - + async def execute( self, content: str, + media: list[str] | None = None, channel: str | None = None, chat_id: str | None = None, - message_id: str | None = None, - media: list[str] | None = None, **kwargs: Any ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - message_id = message_id or self._default_message_id if not channel or not chat_id: return "Error: No target channel/chat specified" @@ -93,17 +86,18 @@ class MessageTool(Tool): channel=channel, chat_id=chat_id, content=content, - media=media or [], - metadata={ - "message_id": message_id, - } + media=media or [] ) - + try: await self._send_callback(msg) - if channel == self._default_channel and chat_id == self._default_chat_id: - self._sent_in_turn = True - media_info = f" with {len(media)} attachments" if media else "" - return f"Message sent to {channel}:{chat_id}{media_info}" + + if self._sessions: + session_key = f"{channel}:{chat_id}" + session = self._sessions.get_or_create(session_key) + session.add_message("assistant", content) + self._sessions.save(session) + + return f"Message sent to {channel}:{chat_id}" except Exception as e: return f"Error sending message: {str(e)}" diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 3af4aef..f1c61d6 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -32,35 +32,68 @@ class ToolRegistry: return name in self._tools def get_definitions(self) -> list[dict[str, Any]]: - """Get all tool definitions in OpenAI format.""" - return [tool.to_schema() for tool in self._tools.values()] - - async def execute(self, name: str, params: dict[str, Any]) -> str: - """Execute a tool by name with given parameters.""" - _HINT = "\n\n[Analyze the error above and try a different approach.]" + """Get tool definitions for all registered tools. + Supports both function tools (with to_schema) and native tools (with to_params). + """ + definitions = [] + for tool in self._tools.values(): + if hasattr(tool, 'to_params'): # Native Anthropic tool + definitions.append(tool.to_params()) + elif hasattr(tool, 'to_schema'): # Function tool + definitions.append(tool.to_schema()) + else: + raise ValueError(f"Tool {tool.name} has no schema method (to_params or to_schema)") + return definitions + + async def execute(self, name: str, params: dict[str, Any]) -> Any: + """ + Execute a tool by name with given parameters. + + Supports both native Anthropic tools (via __call__) and function tools (via execute). + + Args: + name: Tool name. + params: Tool parameters. + + Returns: + Tool execution result (ToolResult, CLIResult, or string). + + Raises: + KeyError: If tool not found. + """ tool = self._tools.get(name) if not tool: - return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" + return f"Error: Tool '{name}' not found" try: - errors = tool.validate_params(params) - if errors: - return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT - result = await tool.execute(**params) - if isinstance(result, str) and result.startswith("Error"): - return result + _HINT - return result + # Duck typing - support both native and function tools + if hasattr(tool, 'to_params'): + # Native Anthropic tool - call directly via __call__, no validation needed + return await tool(**params) + else: + # Legacy function tool - validate then execute + errors = tool.validate_params(params) + if errors: + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + return await tool.execute(**params) except Exception as e: - return f"Error executing {name}: {str(e)}" + _HINT + return f"Error executing {name}: {str(e)}" + def get_tools(self) -> list[Any]: + """Get list of tool objects (not definitions). + + Returns tool objects which can be inspected for metadata like beta_flag. + """ + return list(self._tools.values()) + @property def tool_names(self) -> list[str]: """Get list of registered tool names.""" return list(self._tools.keys()) - + def __len__(self) -> int: return len(self._tools) - + def __contains__(self, name: str) -> bool: return name in self._tools diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index fb816ca..c62de4c 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -9,19 +9,24 @@ if TYPE_CHECKING: class SpawnTool(Tool): - """Tool to spawn a subagent for background task execution.""" + """ + Tool to spawn a subagent for background task execution. + + The subagent runs asynchronously and announces its result back + to the main agent when complete. + """ def __init__(self, manager: "SubagentManager"): self._manager = manager self._origin_channel = "cli" self._origin_chat_id = "direct" - self._session_key = "cli:direct" - - def set_context(self, channel: str, chat_id: str) -> None: + self._origin_metadata: dict[str, Any] = {} + + def set_context(self, channel: str, chat_id: str, metadata: dict[str, Any] | None = None) -> None: """Set the origin context for subagent announcements.""" self._origin_channel = channel self._origin_chat_id = chat_id - self._session_key = f"{channel}:{chat_id}" + self._origin_metadata = metadata or {} @property def name(self) -> str: @@ -48,16 +53,21 @@ class SpawnTool(Tool): "type": "string", "description": "Optional short label for the task (for display)", }, + "model": { + "type": "string", + "description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.", + }, }, "required": ["task"], } - async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: + async def execute(self, task: str, label: str | None = None, model: str | None = None, **kwargs: Any) -> str: """Spawn a subagent to execute the given task.""" return await self._manager.spawn( task=task, label=label, + model=model, origin_channel=self._origin_channel, origin_chat_id=self._origin_chat_id, - session_key=self._session_key, + origin_metadata=self._origin_metadata, ) diff --git a/nanobot/agent/tools/subagent_message.py b/nanobot/agent/tools/subagent_message.py new file mode 100644 index 0000000..2a554a3 --- /dev/null +++ b/nanobot/agent/tools/subagent_message.py @@ -0,0 +1,72 @@ +"""Message tool for subagents to communicate with the main agent.""" + +from typing import Any, TYPE_CHECKING + +from nanobot.agent.tools.base import Tool +from nanobot.bus.events import InboundMessage + +if TYPE_CHECKING: + from nanobot.bus.queue import MessageBus + + +class SubagentMessageTool(Tool): + """ + Tool for subagents to send messages to the main agent. + + Messages are sent via the bus and preserve metadata (e.g. suppress_output) + from the originating message that spawned the subagent. + """ + + def __init__( + self, + bus: "MessageBus", + origin_channel: str, + origin_chat_id: str, + origin_metadata: dict[str, Any] | None = None, + ): + self._bus = bus + self._origin_channel = origin_channel + self._origin_chat_id = origin_chat_id + self._origin_metadata = origin_metadata or {} + + @property + def name(self) -> str: + return "message" + + @property + def description(self) -> str: + return ( + "Send a message to the main agent. " + "Use this to communicate findings, request clarification, or provide updates. " + "The main agent will process your message and decide how to respond." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The message content to send to the main agent" + }, + }, + "required": ["content"] + } + + async def execute(self, content: str, **kwargs: Any) -> str: + """Send a message to the main agent via the bus.""" + # Create InboundMessage to trigger main agent + msg = InboundMessage( + channel="system", + sender_id="subagent", + chat_id=f"{self._origin_channel}:{self._origin_chat_id}", + content=f"[Subagent message]\n\n{content}", + metadata=self._origin_metadata, + ) + + try: + await self._bus.publish_inbound(msg) + return "Message sent to main agent" + except Exception as e: + return f"Error sending message: {str(e)}" diff --git a/nanobot/agent/tools/wait.py b/nanobot/agent/tools/wait.py new file mode 100644 index 0000000..67fc08f --- /dev/null +++ b/nanobot/agent/tools/wait.py @@ -0,0 +1,50 @@ +"""Wait-for-subagents tool for orchestrator subagents.""" + +from typing import Any, TYPE_CHECKING + +from nanobot.agent.tools.base import Tool + +if TYPE_CHECKING: + from nanobot.agent.subagent import SubagentManager + + +class WaitForSubagentsTool(Tool): + """ + Tool to wait for child subagents to complete and collect their results. + + Use this after spawning multiple subagents to wait for all of them + and get their results for synthesis. + """ + + def __init__(self, manager: "SubagentManager"): + self._manager = manager + + @property + def name(self) -> str: + return "wait_for_subagents" + + @property + def description(self) -> str: + return ( + "Wait for one or more child subagents to complete and return their results. " + "Use this after spawning subagents to collect all results before synthesizing. " + "Blocks until all specified subagents finish." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "task_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of task IDs to wait for (from spawn tool responses)", + }, + }, + "required": ["task_ids"], + } + + async def execute(self, task_ids: list[str], **kwargs: Any) -> str: + """Wait for the specified subagents and return their results.""" + return await self._manager.wait_for(task_ids) diff --git a/nanobot/agent/visibility.py b/nanobot/agent/visibility.py new file mode 100644 index 0000000..4602339 --- /dev/null +++ b/nanobot/agent/visibility.py @@ -0,0 +1,83 @@ +# nanobot/agent/visibility.py +"""Cryptographic signing for visibility markers to prevent model forgery.""" + +import hmac +import hashlib +import re +from typing import Tuple + +SECRET_KEY = "nanobot_visibility_secret_key_v1" + + +def sign_content(content: str) -> str: + """ + Sign content with HMAC and prepend marker. + + Args: + content: The message content to sign + + Returns: + Content with signed visibility marker: "[HIDDEN:{sig}] {content}" + """ + sig = hmac.new( + SECRET_KEY.encode(), + content.encode(), + hashlib.sha256 + ).hexdigest()[:8] + return f"[HIDDEN:{sig}] {content}" + + +def verify_signature(marked_content: str) -> Tuple[bool, str]: + """ + Verify HMAC signature and extract clean content. + + Args: + marked_content: Content potentially with [HIDDEN:{sig}] marker + + Returns: + Tuple of (is_valid, clean_content) + - is_valid: True if signature is valid, False otherwise + - clean_content: Content without marker + """ + match = re.match(r'\[HIDDEN:([a-f0-9]{8})\] (.*)', marked_content, re.DOTALL) + if not match: + return False, marked_content + + claimed_sig, content = match.groups() + expected_sig = hmac.new( + SECRET_KEY.encode(), + content.encode(), + hashlib.sha256 + ).hexdigest()[:8] + + is_valid = hmac.compare_digest(claimed_sig, expected_sig) + return is_valid, content + + +def has_forged_marker(content: str) -> bool: + """ + Check if content has an invalid [HIDDEN:*] marker at the start. + + Args: + content: Content to check + + Returns: + True if content starts with forged marker, False otherwise + """ + if not content.startswith("[HIDDEN:"): + return False + is_valid, _ = verify_signature(content) + return not is_valid + + +def strip_all_hidden_markers(content: str) -> str: + """ + Remove all [HIDDEN:*] patterns from content (valid or invalid). + + Args: + content: Content potentially with markers + + Returns: + Content with all markers stripped + """ + return re.sub(r'\[HIDDEN:[a-f0-9]{8}\]\s*', '', content) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 7c0616f..e553687 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -1,6 +1,9 @@ """Async message queue for decoupled channel-agent communication.""" import asyncio +from typing import Callable, Awaitable + +from loguru import logger from nanobot.bus.events import InboundMessage, OutboundMessage @@ -8,36 +11,92 @@ from nanobot.bus.events import InboundMessage, OutboundMessage class MessageBus: """ Async message bus that decouples chat channels from the agent core. - + Channels push messages to the inbound queue, and the agent processes them and pushes responses to the outbound queue. """ - + def __init__(self): self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() - + self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {} + self._correlation_store: dict[str, asyncio.Future] = {} + self._running = False + async def publish_inbound(self, msg: InboundMessage) -> None: """Publish a message from a channel to the agent.""" await self.inbound.put(msg) - + async def consume_inbound(self) -> InboundMessage: """Consume the next inbound message (blocks until available).""" return await self.inbound.get() - + async def publish_outbound(self, msg: OutboundMessage) -> None: """Publish a response from the agent to channels.""" await self.outbound.put(msg) - + async def consume_outbound(self) -> OutboundMessage: """Consume the next outbound message (blocks until available).""" return await self.outbound.get() + def register_correlation(self, correlation_id: str) -> asyncio.Future: + """Register a Future to be resolved when a matching outbound message appears.""" + loop = asyncio.get_running_loop() + future = loop.create_future() + self._correlation_store[correlation_id] = future + return future + + def resolve_correlation(self, msg: OutboundMessage) -> None: + """Check if an outbound message has a correlation_id and resolve the matching Future.""" + cid = msg.metadata.get("correlation_id") if msg.metadata else None + if cid and cid in self._correlation_store: + future = self._correlation_store.pop(cid) + if not future.done(): + future.set_result(msg.content) + + def cancel_correlation(self, correlation_id: str) -> None: + """Cancel and remove a pending correlation.""" + future = self._correlation_store.pop(correlation_id, None) + if future and not future.done(): + future.cancel() + + def subscribe_outbound( + self, + channel: str, + callback: Callable[[OutboundMessage], Awaitable[None]] + ) -> None: + """Subscribe to outbound messages for a specific channel.""" + if channel not in self._outbound_subscribers: + self._outbound_subscribers[channel] = [] + self._outbound_subscribers[channel].append(callback) + + async def dispatch_outbound(self) -> None: + """ + Dispatch outbound messages to subscribed channels. + Run this as a background task. + """ + self._running = True + while self._running: + try: + msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0) + subscribers = self._outbound_subscribers.get(msg.channel, []) + for callback in subscribers: + try: + await callback(msg) + except Exception as e: + logger.error(f"Error dispatching to {msg.channel}: {e}") + except asyncio.TimeoutError: + continue + + def stop(self) -> None: + """Stop the dispatcher loop.""" + self._running = False + @property def inbound_size(self) -> int: """Number of pending inbound messages.""" return self.inbound.qsize() - + @property def outbound_size(self) -> int: """Number of pending outbound messages.""" diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 3010373..788b541 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -69,11 +69,15 @@ class BaseChannel(ABC): True if allowed, False otherwise. """ allow_list = getattr(self.config, "allow_from", []) - + # If no allow list, allow everyone if not allow_list: return True - + + # Wildcard allows everyone + if "*" in allow_list: + return True + sender_str = str(sender_id) if sender_str in allow_list: return True diff --git a/nanobot/channels/hook.py b/nanobot/channels/hook.py new file mode 100644 index 0000000..87ff677 --- /dev/null +++ b/nanobot/channels/hook.py @@ -0,0 +1,38 @@ +"""Hook channel — receives outbound messages from hook-initiated conversations.""" + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus + + +class HookChannel: + """ + Minimal channel for hook-initiated conversations. + + The hook HTTP server publishes InboundMessages to the bus. + Responses come back as OutboundMessages routed here. + send() is a no-op because the HTTP caller gets the response + via bus correlation, not channel delivery. + """ + + name = "hook" + + def __init__(self, bus: MessageBus): + self.bus = bus + self._running = False + + async def start(self) -> None: + self._running = True + logger.info("Hook channel started") + + async def stop(self) -> None: + self._running = False + + async def send(self, msg: OutboundMessage) -> None: + """No-op — response is returned via bus correlation to the HTTP caller.""" + logger.debug(f"Hook channel received outbound for {msg.chat_id} (no-op)") + + @property + def is_running(self) -> bool: + return self._running diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index c8df6b2..1bec7cf 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -149,6 +149,11 @@ class ChannelManager: except ImportError as e: logger.warning("Matrix channel not available: {}", e) + def register_channel(self, name: str, channel: BaseChannel) -> None: + """Register an external channel.""" + self.channels[name] = channel + logger.info(f"{name} channel registered") + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -204,13 +209,16 @@ class ChannelManager: self.bus.consume_outbound(), timeout=1.0 ) - + if msg.metadata.get("_progress"): if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: continue if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: continue - + + # Resolve any pending correlation (hook request-response) + self.bus.resolve_correlation(msg) + channel = self.channels.get(msg.channel) if channel: try: diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 969d853..99e012b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,8 +4,10 @@ from __future__ import annotations import asyncio import re +from pathlib import Path + from loguru import logger -from telegram import BotCommand, Update, ReplyParameters +from telegram import BotCommand, Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.request import HTTPXRequest @@ -78,26 +80,6 @@ def _markdown_to_telegram_html(text: str) -> str: return text -def _split_message(content: str, max_len: int = 4000) -> list[str]: - """Split content into chunks within max_len, preferring line breaks.""" - if len(content) <= max_len: - return [content] - chunks: list[str] = [] - while content: - if len(content) <= max_len: - chunks.append(content) - break - cut = content[:max_len] - pos = cut.rfind('\n') - if pos == -1: - pos = cut.rfind(' ') - if pos == -1: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class TelegramChannel(BaseChannel): """ Telegram channel using long polling. @@ -111,8 +93,8 @@ class TelegramChannel(BaseChannel): BOT_COMMANDS = [ BotCommand("start", "Start the bot"), BotCommand("new", "Start a new conversation"), - BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), + BotCommand("quota", "Show current quota status"), ] def __init__( @@ -127,8 +109,6 @@ class TelegramChannel(BaseChannel): self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task - self._media_group_buffers: dict[str, dict] = {} - self._media_group_tasks: dict[str, asyncio.Task] = {} async def start(self) -> None: """Start the Telegram bot with long polling.""" @@ -149,7 +129,8 @@ class TelegramChannel(BaseChannel): # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) - self._app.add_handler(CommandHandler("help", self._on_help)) + self._app.add_handler(CommandHandler("help", self._forward_command)) + self._app.add_handler(CommandHandler("quota", self._forward_command)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -168,13 +149,13 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() - logger.info("Telegram bot @{} connected", bot_info.username) + logger.info(f"Telegram bot @{bot_info.username} connected") try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: - logger.warning("Failed to register bot commands: {}", e) + logger.warning(f"Failed to register bot commands: {e}") # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -193,11 +174,6 @@ class TelegramChannel(BaseChannel): # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) - - for task in self._media_group_tasks.values(): - task.cancel() - self._media_group_tasks.clear() - self._media_group_buffers.clear() if self._app: logger.info("Stopping Telegram bot...") @@ -206,123 +182,264 @@ class TelegramChannel(BaseChannel): await self._app.shutdown() self._app = None - @staticmethod - def _get_media_type(path: str) -> str: - """Guess media type from file extension.""" - ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" - if ext in ("jpg", "jpeg", "png", "gif", "webp"): - return "photo" - if ext == "ogg": - return "voice" - if ext in ("mp3", "m4a", "wav", "aac"): - return "audio" - return "document" - async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" if not self._app: logger.warning("Telegram bot not running") return + # Stop typing indicator for this chat self._stop_typing(msg.chat_id) + # Check for suppression + if msg.metadata.get("suppressed", False): + logger.debug(f"Suppressed output (not sent to Telegram): {msg.content[:100]}...") + return # Don't send to Telegram API + try: + # chat_id should be the Telegram chat ID (integer) chat_id = int(msg.chat_id) + # Convert markdown to Telegram HTML + html_content = _markdown_to_telegram_html(msg.content) + + # Check if message has media attachments + if msg.media: + await self._send_with_media(chat_id, html_content, msg.media) + else: + # Text-only message - split if too long + await self._send_text_chunks(chat_id, html_content, parse_mode="HTML") except ValueError: - logger.error("Invalid chat_id: {}", msg.chat_id) + logger.error(f"Invalid chat_id: {msg.chat_id}") + except Exception as e: + # Fallback to plain text if HTML parsing fails + logger.warning(f"HTML parse failed, falling back to plain text: {e}") + try: + await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None) + except Exception as e2: + logger.error(f"Error sending Telegram message: {e2}") + + @staticmethod + def _split_message(content: str, max_len: int = 4000) -> list[str]: + """Split content into chunks within max_len, preferring line breaks. + + From upstream HKUDS/nanobot - battle-tested implementation. + Uses 4000 char limit (safer than 4096) with split priority: \n → space → hard cut. + """ + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + async def _send_text_chunks( + self, + chat_id: int, + text: str, + parse_mode: str | None = "HTML" + ) -> None: + """Split and send long messages. + + Telegram has a 4096 character limit per message. + Uses upstream's proven implementation - splits at line breaks, then spaces. + """ + chunks = self._split_message(text) + + for chunk in chunks: + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk.strip(), + parse_mode=parse_mode + ) + + async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None: + """ + Send message with media attachments. + + Args: + chat_id: Telegram chat ID + caption: Message caption + media_paths: List of file paths or URLs + """ + from telegram import InputMediaPhoto, InputMediaVideo + + from nanobot.channels.telegram_media import ( + MediaKind, + classify_media, + detect_mime, + fetch_media, + group_media_for_album, + optimize_image, + ) + + # Process each media item + processed_media: list[tuple[str, MediaKind, bytes, str]] = [] + + for path in media_paths: + try: + # Fetch remote URLs + if path.startswith(("http://", "https://")): + content, mime = await fetch_media(path, max_bytes=100_000_000) + kind = classify_media(mime) + # Extract filename from URL + filename = Path(path).name + else: + # Local file + file_path = Path(path) + if not file_path.exists(): + logger.warning(f"Media file not found: {path}") + continue + + with open(file_path, "rb") as f: + content = f.read() + + mime = detect_mime(path, content) + kind = classify_media(mime) + # Extract filename from local path + filename = file_path.name + + # Optimize images + if kind == MediaKind.IMAGE: + try: + content = optimize_image(path, max_bytes=6_000_000) + except Exception as e: + logger.warning(f"Image optimization failed: {e}, sending original") + + processed_media.append((path, kind, content, filename)) + + except Exception as e: + logger.error(f"Failed to process media {path}: {e}") + continue + + if not processed_media: + # No media could be processed, send text only + await self._app.bot.send_message( + chat_id=chat_id, + text=caption, + parse_mode="HTML" + ) return - reply_params = None - if self.config.reply_to_message: - reply_to_message_id = msg.metadata.get("message_id") - if reply_to_message_id: - reply_params = ReplyParameters( - message_id=reply_to_message_id, - allow_sending_without_reply=True - ) + # Group media for album sending + media_items = [(path, kind) for path, kind, _, _ in processed_media] + grouping = group_media_for_album(media_items) - # Send media files - for media_path in (msg.media or []): - try: - media_type = self._get_media_type(media_path) - sender = { - "photo": self._app.bot.send_photo, - "voice": self._app.bot.send_voice, - "audio": self._app.bot.send_audio, - }.get(media_type, self._app.bot.send_document) - param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" - with open(media_path, 'rb') as f: - await sender( - chat_id=chat_id, - **{param: f}, - reply_parameters=reply_params + # Handle caption length (Telegram limit: 1024 chars) + if len(caption) > 1024: + # Send media without caption, then follow-up text + media_caption = None + followup_text = caption + else: + media_caption = caption + followup_text = None + + # Send album if grouped + if grouping["album"]: + album_paths = grouping["album"] + album_media = [] + + for path, kind, content, filename in processed_media: + if path not in album_paths: + continue + + if kind == MediaKind.IMAGE: + media_obj = InputMediaPhoto( + media=content, + caption=media_caption if len(album_media) == 0 else None, + parse_mode="HTML" if media_caption else None ) - except Exception as e: - filename = media_path.rsplit("/", 1)[-1] - logger.error("Failed to send media {}: {}", media_path, e) - await self._app.bot.send_message( + elif kind == MediaKind.VIDEO: + media_obj = InputMediaVideo( + media=content, + caption=media_caption if len(album_media) == 0 else None, + parse_mode="HTML" if media_caption else None + ) + else: + continue # Skip non-album types + + album_media.append(media_obj) + + if album_media: + await self._app.bot.send_media_group( chat_id=chat_id, - text=f"[Failed to send: {filename}]", - reply_parameters=reply_params + media=album_media ) - # Send text content - if msg.content and msg.content != "[empty message]": - for chunk in _split_message(msg.content): - try: - html = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message( - chat_id=chat_id, - text=html, - parse_mode="HTML", - reply_parameters=reply_params - ) - except Exception as e: - logger.warning("HTML parse failed, falling back to plain text: {}", e) - try: - await self._app.bot.send_message( - chat_id=chat_id, - text=chunk, - reply_parameters=reply_params - ) - except Exception as e2: - logger.error("Error sending Telegram message: {}", e2) - + # Send separate media + for i, (path, kind, content, filename) in enumerate(processed_media): + if path in grouping["album"]: + continue # Already sent in album + + # Only first separate item gets caption + item_caption = media_caption if i == 0 else None + + if kind == MediaKind.IMAGE: + await self._app.bot.send_photo( + chat_id=chat_id, + photo=content, + caption=item_caption, + parse_mode="HTML" if item_caption else None + ) + elif kind == MediaKind.VIDEO: + await self._app.bot.send_video( + chat_id=chat_id, + video=content, + caption=item_caption, + parse_mode="HTML" if item_caption else None + ) + elif kind == MediaKind.AUDIO: + await self._app.bot.send_audio( + chat_id=chat_id, + audio=content, + caption=item_caption, + parse_mode="HTML" if item_caption else None, + filename=filename + ) + elif kind == MediaKind.DOCUMENT: + await self._app.bot.send_document( + chat_id=chat_id, + document=content, + caption=item_caption, + parse_mode="HTML" if item_caption else None, + filename=filename + ) + + # Send follow-up text if caption was too long + if followup_text: + await self._app.bot.send_message( + chat_id=chat_id, + text=followup_text, + parse_mode="HTML" + ) + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: return - + user = update.effective_user await update.message.reply_text( f"👋 Hi {user.first_name}! I'm nanobot.\n\n" "Send me a message and I'll respond!\n" "Type /help to see available commands." ) - - async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /help command, bypassing ACL so all users can access it.""" - if not update.message: - return - await update.message.reply_text( - "🐈 nanobot commands:\n" - "/new — Start a new conversation\n" - "/stop — Stop the current task\n" - "/help — Show available commands" - ) - - @staticmethod - def _sender_id(user) -> str: - """Build sender_id with username for allowlist matching.""" - sid = str(user.id) - return f"{sid}|{user.username}" if user.username else sid - + async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Forward slash commands to the bus for unified handling in AgentLoop.""" if not update.message or not update.effective_user: return await self._handle_message( - sender_id=self._sender_id(update.effective_user), + sender_id=str(update.effective_user.id), chat_id=str(update.message.chat_id), content=update.message.text, ) @@ -335,7 +452,11 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user chat_id = message.chat_id - sender_id = self._sender_id(user) + + # Use stable numeric ID, but keep username for allowlist compatibility + sender_id = str(user.id) + if user.username: + sender_id = f"{sender_id}|{user.username}" # Store chat_id for replies self._chat_ids[sender_id] = chat_id @@ -389,45 +510,23 @@ class TelegramChannel(BaseChannel): transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcription = await transcriber.transcribe(file_path) if transcription: - logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + logger.info(f"Transcribed {media_type}: {transcription[:50]}...") content_parts.append(f"[transcription: {transcription}]") else: content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - logger.debug("Downloaded {} to {}", media_type, file_path) + logger.debug(f"Downloaded {media_type} to {file_path}") except Exception as e: - logger.error("Failed to download media: {}", e) + logger.error(f"Failed to download media: {e}") content_parts.append(f"[{media_type}: download failed]") content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) + logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") str_chat_id = str(chat_id) - - # Telegram media groups: buffer briefly, forward as one aggregated turn. - if media_group_id := getattr(message, "media_group_id", None): - key = f"{str_chat_id}:{media_group_id}" - if key not in self._media_group_buffers: - self._media_group_buffers[key] = { - "sender_id": sender_id, "chat_id": str_chat_id, - "contents": [], "media": [], - "metadata": { - "message_id": message.message_id, "user_id": user.id, - "username": user.username, "first_name": user.first_name, - "is_group": message.chat.type != "private", - }, - } - self._start_typing(str_chat_id) - buf = self._media_group_buffers[key] - if content and content != "[empty message]": - buf["contents"].append(content) - buf["media"].extend(media_paths) - if key not in self._media_group_tasks: - self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key)) - return # Start typing indicator before processing self._start_typing(str_chat_id) @@ -447,21 +546,6 @@ class TelegramChannel(BaseChannel): } ) - async def _flush_media_group(self, key: str) -> None: - """Wait briefly, then forward buffered media-group as one turn.""" - try: - await asyncio.sleep(0.6) - if not (buf := self._media_group_buffers.pop(key, None)): - return - content = "\n".join(buf["contents"]) or "[empty message]" - await self._handle_message( - sender_id=buf["sender_id"], chat_id=buf["chat_id"], - content=content, media=list(dict.fromkeys(buf["media"])), - metadata=buf["metadata"], - ) - finally: - self._media_group_tasks.pop(key, None) - def _start_typing(self, chat_id: str) -> None: """Start sending 'typing...' indicator for a chat.""" # Cancel any existing typing task for this chat @@ -483,11 +567,11 @@ class TelegramChannel(BaseChannel): except asyncio.CancelledError: pass except Exception as e: - logger.debug("Typing indicator stopped for {}: {}", chat_id, e) + logger.debug(f"Typing indicator stopped for {chat_id}: {e}") async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" - logger.error("Telegram error: {}", context.error) + logger.error(f"Telegram error: {context.error}") def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" diff --git a/nanobot/channels/telegram_media.py b/nanobot/channels/telegram_media.py new file mode 100644 index 0000000..94beba2 --- /dev/null +++ b/nanobot/channels/telegram_media.py @@ -0,0 +1,286 @@ +"""Media handling utilities for Telegram channel.""" + +from __future__ import annotations + +import io +import mimetypes +from enum import Enum +from pathlib import Path + +import httpx +from loguru import logger +from PIL import Image + +# Telegram API photo size limit (6MB) +TELEGRAM_PHOTO_SIZE_LIMIT = 6_000_000 + +try: + import magic + HAS_MAGIC = True +except ImportError: + HAS_MAGIC = False + +try: + from pillow_heif import register_heif_opener + register_heif_opener() + HAS_HEIF = True +except ImportError: + HAS_HEIF = False + + +class MediaKind(Enum): + """Media type classification.""" + IMAGE = "image" + VIDEO = "video" + AUDIO = "audio" + DOCUMENT = "document" + + +def detect_mime(path: str, content: bytes | None = None) -> str: + """ + Detect MIME type of media file. + + Priority: + 1. python-magic sniff (if available and content provided) + 2. Extension-based lookup + 3. Fallback to application/octet-stream + + Args: + path: File path (used for extension detection) + content: Optional file content bytes for magic sniffing + + Returns: + MIME type string (e.g., "image/jpeg") + """ + # Try magic detection first if we have content + if HAS_MAGIC and content: + try: + mime = magic.from_buffer(content, mime=True) + # Avoid generic types if we can be more specific from extension + if mime and mime != "application/octet-stream": + return mime + except Exception as e: + logger.debug(f"Magic detection failed, falling back to extension: {e}") + + # Extension-based detection + mime_type, _ = mimetypes.guess_type(path) + if mime_type: + return mime_type + + # Fallback + return "application/octet-stream" + + +def classify_media(mime: str) -> MediaKind: + """ + Classify MIME type into media kind. + + Args: + mime: MIME type string (e.g., "image/jpeg") + + Returns: + MediaKind enum value + """ + if mime.startswith("image/"): + return MediaKind.IMAGE + if mime.startswith("video/"): + return MediaKind.VIDEO + if mime.startswith("audio/"): + return MediaKind.AUDIO + # Everything else is a document + return MediaKind.DOCUMENT + + +def is_heic_format(path: str) -> bool: + """ + Check if file is HEIC/HEIF format. + + Args: + path: File path + + Returns: + True if file extension is .heic or .heif + """ + ext = Path(path).suffix.lower() + return ext in (".heic", ".heif") + + +async def fetch_media(url: str, max_bytes: int) -> tuple[bytes, str]: + """ + Download media from remote URL. + + Args: + url: Remote URL to fetch + max_bytes: Maximum size to download + + Returns: + Tuple of (content bytes, detected MIME type) + + Raises: + ValueError: If download fails or exceeds size limit + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url, follow_redirects=True) + response.raise_for_status() + + content = response.content + + if len(content) > max_bytes: + raise ValueError(f"Media exceeds size limit: {len(content)} > {max_bytes}") + + # Get MIME type from response or detect + mime = response.headers.get("content-type", "application/octet-stream") + # Strip charset if present (e.g., "image/jpeg; charset=utf-8" → "image/jpeg") + mime = mime.split(";")[0].strip() + + # Detect from content if generic type + if mime == "application/octet-stream": + mime = detect_mime(url, content) + + return content, mime + + except httpx.TimeoutException as e: + raise ValueError(f"Download timeout: {url}") from e + except httpx.HTTPError as e: + raise ValueError(f"Download failed: {url}: {e}") from e + + +def optimize_image(path: str, max_bytes: int = TELEGRAM_PHOTO_SIZE_LIMIT) -> bytes: + """ + Optimize image to fit under size limit. + + Strategy: + 1. Convert HEIC to JPEG if needed + 2. PNG with alpha → preserve with compression levels [6,7,8,9] + 3. JPEG/PNG without alpha → resize + quality grid + + Sizes: [2048, 1536, 1280, 1024, 800] px (max dimension) + Qualities: [80, 70, 60, 50, 40] (JPEG only) + + Args: + path: Path to image file + max_bytes: Maximum size in bytes (default 6MB for Telegram) + + Returns: + Optimized image bytes + + Raises: + ValueError: If image cannot be optimized under limit + """ + # Load image with context manager to ensure file handle is closed + with Image.open(path) as img: + # Convert HEIC to JPEG + if is_heic_format(path): + if not HAS_HEIF: + raise ValueError("pillow-heif not available for HEIC conversion") + # Convert to RGB (HEIC → JPEG) + if img.mode != "RGB": + img = img.convert("RGB") + return _optimize_jpeg(img, max_bytes) + + # PNG with alpha channel - preserve it + if img.mode == "RGBA" or img.mode == "LA": + return _optimize_png(img, max_bytes) + + # Everything else → convert to JPEG and optimize + if img.mode != "RGB": + img = img.convert("RGB") + return _optimize_jpeg(img, max_bytes) + + +def _optimize_jpeg(img: Image.Image, max_bytes: int) -> bytes: + """Optimize JPEG with size/quality grid.""" + sizes = [2048, 1536, 1280, 1024, 800] + qualities = [80, 70, 60, 50, 40] + + for size in sizes: + # Always copy to avoid mutation issues + resized = img.copy() + if max(img.size) > size: + resized.thumbnail((size, size), Image.Resampling.LANCZOS) + + for quality in qualities: + buf = io.BytesIO() + resized.save(buf, format="JPEG", quality=quality, optimize=True) + data = buf.getvalue() + + if len(data) <= max_bytes: + return data + + # If we get here, even smallest size/quality is too large + raise ValueError(f"Cannot optimize image under {max_bytes} bytes") + + +def _optimize_png(img: Image.Image, max_bytes: int) -> bytes: + """Optimize PNG while preserving alpha channel.""" + compress_levels = [6, 7, 8, 9] + sizes = [2048, 1536, 1280, 1024, 800] + + for size in sizes: + # Always copy to avoid mutation issues + resized = img.copy() + if max(img.size) > size: + resized.thumbnail((size, size), Image.Resampling.LANCZOS) + + for compress_level in compress_levels: + buf = io.BytesIO() + resized.save(buf, format="PNG", compress_level=compress_level, optimize=True) + data = buf.getvalue() + + if len(data) <= max_bytes: + return data + + # Fallback: try converting to JPEG if still too large + if img.mode in ("RGBA", "LA"): + # Create white background + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "RGBA": + background.paste(img, mask=img.split()[3]) # Use alpha as mask + else: # LA (grayscale + alpha) + background.paste(img.convert("L"), mask=img.split()[1]) + return _optimize_jpeg(background, max_bytes) + + raise ValueError(f"Cannot optimize PNG under {max_bytes} bytes") + + +def group_media_for_album(media_items: list[tuple[str, MediaKind]]) -> dict[str, list[str]]: + """ + Group media items for album sending. + + Logic: + - All images (2+) → album + - All videos (2+) → album + - Mixed types → separate + - Single item → separate + + Args: + media_items: List of (path, MediaKind) tuples + + Returns: + Dict with 'album' and 'separate' keys containing lists of paths + """ + if len(media_items) <= 1: + return { + "album": [], + "separate": [path for path, _ in media_items] + } + + # Count each kind + kinds = [kind for _, kind in media_items] + unique_kinds = set(kinds) + + # All same type → album (if images or videos) + if len(unique_kinds) == 1: + kind = kinds[0] + if kind in (MediaKind.IMAGE, MediaKind.VIDEO): + return { + "album": [path for path, _ in media_items], + "separate": [] + } + + # Mixed types or non-album-able types → separate + return { + "album": [], + "separate": [path for path, _ in media_items] + } diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fc4c261..f667ace 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -200,40 +200,23 @@ def onboard(): def _make_provider(config: Config): - """Create the appropriate LLM provider from config.""" - from nanobot.providers.litellm_provider import LiteLLMProvider - from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.providers.custom_provider import CustomProvider + """Create LLM provider from config. Uses OAuth for subscription tokens.""" + from nanobot.providers import create_provider + p = config.get_provider() model = config.agents.defaults.model - provider_name = config.get_provider_name(model) - p = config.get_provider(model) - - # OpenAI Codex (OAuth) - if provider_name == "openai_codex" or model.startswith("openai-codex/"): - return OpenAICodexProvider(default_model=model) - - # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM - if provider_name == "custom": - return CustomProvider( - api_key=p.api_key if p else "no-key", - api_base=config.get_api_base(model) or "http://localhost:8000/v1", - default_model=model, - ) - - from nanobot.providers.registry import find_by_name - spec = find_by_name(provider_name) - if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): + if not (p and p.api_key) and not model.startswith("bedrock/"): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) - return LiteLLMProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, + return create_provider( + api_key=p.api_key if p else "", + model=model, + api_base=config.get_api_base(), extra_headers=p.extra_headers if p else None, - provider_name=provider_name, + provider_name=config.get_provider_name(), + thinking_budget=config.agents.defaults.thinking_budget, ) @@ -287,6 +270,7 @@ def gateway( exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, + enable_memory_tool=config.tools.enable_memory_tool, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, @@ -445,6 +429,7 @@ def agent( exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, + enable_memory_tool=config.tools.enable_memory_tool, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) @@ -935,6 +920,7 @@ def cron_run( brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, + enable_memory_tool=config.tools.enable_memory_tool, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, ) diff --git a/nanobot/cli/oauth.py b/nanobot/cli/oauth.py new file mode 100644 index 0000000..f84c33f --- /dev/null +++ b/nanobot/cli/oauth.py @@ -0,0 +1,93 @@ +"""OAuth CLI commands for subscription authentication.""" + +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +oauth_app = typer.Typer(help="Manage OAuth authentication for subscription-based providers") +console = Console() + + +@oauth_app.command("login") +def login( + provider: str = typer.Argument("anthropic", help="Provider name"), + token: Optional[str] = typer.Option(None, "--token", "-t", help="OAuth token (from claude setup-token)"), +): + """Login to a provider using OAuth. + + For Anthropic Claude Max/Pro, run 'claude setup-token' and paste the token here. + + Example: + nanobot oauth login anthropic --token sk-ant-oat01-xxx + """ + from nanobot.config.oauth_store import OAuthStore + from nanobot.config.schema import OAuthCredentials + + if provider != "anthropic": + console.print(f"[red]OAuth login for {provider} not yet supported[/red]") + return + + if not token: + console.print("Please provide your OAuth token:") + console.print(" 1. Run: claude setup-token") + console.print(" 2. Copy the sk-ant-oat01-... token") + console.print(" 3. Run: nanobot oauth login anthropic --token ") + console.print() + token = typer.prompt("Token", hide_input=True) + + if not token or "sk-ant-oat" not in token: + console.print("[red]Invalid token. Must contain sk-ant-oat[/red]") + return + + store = OAuthStore(Path.home() / ".nanobot") + creds = OAuthCredentials( + access_token=token, + token_type="token" # setup-token doesn't expire + ) + store.save(provider, creds) + + console.print(f"[green]Successfully saved {provider} OAuth credentials![/green]") + + +@oauth_app.command("status") +def status(): + """Show OAuth credential status.""" + from nanobot.config.oauth_store import OAuthStore + + store = OAuthStore(Path.home() / ".nanobot") + + providers = ["anthropic"] + found_any = False + + for provider in providers: + creds = store.load(provider) + if creds: + found_any = True + st = "valid" + if creds.is_expired: + st = "EXPIRED" + elif creds.expires_soon: + st = "expires soon" + + token_preview = creds.access_token[:20] + "..." + console.print(f" {provider}: {token_preview} ({st})") + + if not found_any: + console.print("No OAuth credentials configured.") + console.print("Run: nanobot oauth login anthropic --token ") + + +@oauth_app.command("logout") +def logout( + provider: str = typer.Argument("anthropic", help="Provider name"), +): + """Remove OAuth credentials for a provider.""" + from nanobot.config.oauth_store import OAuthStore + + store = OAuthStore(Path.home() / ".nanobot") + if store.delete(provider): + console.print(f"[green]Removed {provider} OAuth credentials[/green]") + else: + console.print(f"No credentials found for {provider}") diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index c789efd..88a604d 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -11,12 +11,29 @@ def get_config_path() -> Path: return Path.home() / ".nanobot" / "config.json" +def _get_oauth_store_dir() -> Path: + """Get the OAuth store directory.""" + return Path.home() / ".nanobot" + + def get_data_dir() -> Path: """Get the nanobot data directory.""" from nanobot.utils.helpers import get_data_path return get_data_path() +def _inject_oauth_credentials(config: Config) -> Config: + """Inject OAuth credentials from store into config if available.""" + from nanobot.config.oauth_store import OAuthStore + + store = OAuthStore(_get_oauth_store_dir()) + creds = store.load("anthropic") + if creds and creds.access_token and not creds.is_expired: + config.providers.anthropic.api_key = creds.access_token + + return config + + def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. @@ -34,12 +51,13 @@ def load_config(config_path: Path | None = None) -> Config: with open(path, encoding="utf-8") as f: data = json.load(f) data = _migrate_config(data) - return Config.model_validate(data) + config = Config.model_validate(data) + return _inject_oauth_credentials(config) except (json.JSONDecodeError, ValueError) as e: print(f"Warning: Failed to load config from {path}: {e}") print("Using default configuration.") - return Config() + return _inject_oauth_credentials(Config()) def save_config(config: Config, config_path: Path | None = None) -> None: diff --git a/nanobot/config/oauth_store.py b/nanobot/config/oauth_store.py new file mode 100644 index 0000000..eb4ceff --- /dev/null +++ b/nanobot/config/oauth_store.py @@ -0,0 +1,59 @@ +"""OAuth credential storage.""" + +import json +from pathlib import Path +from typing import Any + +from nanobot.config.schema import OAuthCredentials + + +class OAuthStore: + """Stores OAuth credentials in a JSON file.""" + + FILENAME = "oauth-credentials.json" + + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.file_path = config_dir / self.FILENAME + + def _load_all(self) -> dict[str, Any]: + """Load all credentials from file.""" + if not self.file_path.exists(): + return {} + + with open(self.file_path, "r") as f: + return json.load(f) + + def _save_all(self, data: dict[str, Any]) -> None: + """Save all credentials to file.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + + with open(self.file_path, "w") as f: + json.dump(data, f, indent=2) + + # Secure permissions + self.file_path.chmod(0o600) + + def save(self, provider: str, credentials: OAuthCredentials) -> None: + """Save credentials for a provider.""" + data = self._load_all() + data[provider] = credentials.model_dump() + self._save_all(data) + + def load(self, provider: str) -> OAuthCredentials | None: + """Load credentials for a provider.""" + data = self._load_all() + if provider not in data: + return None + + return OAuthCredentials(**data[provider]) + + def delete(self, provider: str) -> bool: + """Delete credentials for a provider.""" + data = self._load_all() + if provider not in data: + return False + + del data[provider] + self._save_all(data) + return True diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1ff9782..2418286 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -226,6 +226,7 @@ class AgentDefaults(Base): temperature: float = 0.1 max_tool_iterations: int = 40 memory_window: int = 100 + thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking class AgentsConfig(Base): @@ -234,12 +235,42 @@ class AgentsConfig(Base): defaults: AgentDefaults = Field(default_factory=AgentDefaults) +class OAuthCredentials(BaseModel): + """OAuth token credentials for subscription-based auth.""" + access_token: str = "" + refresh_token: str = "" + expires_at: int = 0 # Unix timestamp + token_type: str = "oauth" # "oauth" or "token" (setup-token) + + @property + def is_oauth_token(self) -> bool: + """Check if this is an OAuth token (vs regular API key).""" + return "sk-ant-oat" in self.access_token + + @property + def is_expired(self) -> bool: + """Check if token has expired.""" + import time + if self.expires_at == 0: + return False # No expiry set (setup-token) + return time.time() > self.expires_at + + @property + def expires_soon(self) -> bool: + """Check if token expires within 10 minutes.""" + import time + if self.expires_at == 0: + return False + return time.time() > (self.expires_at - 600) + + class ProviderConfig(Base): """LLM provider configuration.""" api_key: str = "" api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) + oauth_credentials: OAuthCredentials | None = None class ProvidersConfig(Base): @@ -279,6 +310,26 @@ class GatewayConfig(Base): heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) +class HooksConfig(Base): + """Webhook endpoint configuration.""" + enabled: bool = False + tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret} + path: str = "/hooks" # URL path for the endpoint + timeout_seconds: int = 120 # Max time to wait for agent response + + def resolve_token(self, provided: str) -> str | None: + """Return token name if provided secret matches, else None.""" + for name, secret in self.tokens.items(): + if secret == provided: + return name + return None + + @property + def has_tokens(self) -> bool: + """True if at least one token is configured.""" + return bool(self.tokens) + + class WebSearchConfig(Base): """Web search tool configuration.""" @@ -316,6 +367,7 @@ class ToolsConfig(Base): web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory + enable_memory_tool: bool = True # If true, enable Anthropic's native memory tool mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) @@ -326,6 +378,7 @@ class Config(BaseSettings): channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) + hooks: HooksConfig = Field(default_factory=HooksConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) @property diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index e534017..171f6cd 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -9,64 +9,62 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine from loguru import logger if TYPE_CHECKING: - from nanobot.providers.base import LLMProvider + from nanobot.session.manager import SessionManager -_HEARTBEAT_TOOL = [ - { - "type": "function", - "function": { - "name": "heartbeat", - "description": "Report heartbeat decision after reviewing tasks.", - "parameters": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["skip", "run"], - "description": "skip = nothing to do, run = has active tasks", - }, - "tasks": { - "type": "string", - "description": "Natural-language summary of active tasks (required for run)", - }, - }, - "required": ["action"], - }, - }, - } -] +# Default interval: 30 minutes +DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 + +# The prompt sent to agent during heartbeat +HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). +Follow any instructions or tasks listed there. +If nothing needs attention, reply with just: HEARTBEAT_OK""" + +# Token that indicates "nothing to do" +HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" + + +def _is_heartbeat_empty(content: str | None) -> bool: + """Check if HEARTBEAT.md has no actionable content.""" + if not content: + return True + + # Lines to skip: empty, headers, HTML comments, empty checkboxes + skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} + + for line in content.split("\n"): + line = line.strip() + if not line or line.startswith("#") or line.startswith("