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("