Compare commits
97
Commits
@@ -15,6 +15,7 @@ docs/
|
|||||||
*.pyzz
|
*.pyzz
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
.worktrees/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
@@ -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
|
||||||
+16
-12
@@ -45,10 +45,14 @@ class ContextBuilder:
|
|||||||
if bootstrap:
|
if bootstrap:
|
||||||
parts.append(bootstrap)
|
parts.append(bootstrap)
|
||||||
|
|
||||||
# Memory context
|
# Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching)
|
||||||
memory = self.memory.get_memory_context()
|
# MEMORY.md is excluded from system prompt as it changes frequently (consolidator),
|
||||||
if memory:
|
# but the agent can still read/grep it via tools.
|
||||||
parts.append(f"# Memory\n\n{memory}")
|
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
|
# Skills - progressive loading
|
||||||
# 1. Always-loaded skills: include full content
|
# 1. Always-loaded skills: include full content
|
||||||
@@ -72,10 +76,6 @@ Skills with available="false" need dependencies installed first - you can try in
|
|||||||
|
|
||||||
def _get_identity(self) -> str:
|
def _get_identity(self) -> str:
|
||||||
"""Get the core identity section with runtime context."""
|
"""Get the core identity section with runtime context."""
|
||||||
from datetime import datetime
|
|
||||||
import time as _time
|
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
|
||||||
tz = _time.strftime("%Z") or "UTC"
|
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
@@ -87,9 +87,6 @@ Skills with available="false" need dependencies installed first - you can try in
|
|||||||
- Send messages to users on chat channels
|
- Send messages to users on chat channels
|
||||||
- Spawn subagents for complex background tasks
|
- Spawn subagents for complex background tasks
|
||||||
|
|
||||||
## Current Time
|
|
||||||
{now} ({tz})
|
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
{runtime}
|
{runtime}
|
||||||
|
|
||||||
@@ -105,7 +102,14 @@ For normal conversation, just respond with text - do not call the message tool.
|
|||||||
|
|
||||||
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.
|
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
|
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||||
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
To recall past events, grep {workspace_path}/memory/HISTORY.md
|
||||||
|
|
||||||
|
## 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:
|
def _load_bootstrap_files(self) -> str:
|
||||||
"""Load all bootstrap files from workspace."""
|
"""Load all bootstrap files from workspace."""
|
||||||
|
|||||||
+378
-76
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,16 +19,19 @@ from nanobot.agent.tools.shell import ExecTool
|
|||||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.spawn import SpawnTool
|
from nanobot.agent.tools.spawn import SpawnTool
|
||||||
|
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.visibility import sign_content, has_forged_marker, strip_all_hidden_markers
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
"""
|
"""
|
||||||
The agent loop is the core processing engine.
|
The agent loop is the core processing engine.
|
||||||
|
|
||||||
It:
|
It:
|
||||||
1. Receives messages from the bus
|
1. Receives messages from the bus
|
||||||
2. Builds context with history, memory, skills
|
2. Builds context with history, memory, skills
|
||||||
@@ -35,6 +39,22 @@ class AgentLoop:
|
|||||||
4. Executes tool calls
|
4. Executes tool calls
|
||||||
5. Sends responses back
|
5. Sends responses back
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Server-side context management: Anthropic trims old tool results and preserves all
|
||||||
|
# thinking blocks (keep="all" maximises cache hits). Client keeps full history.
|
||||||
|
CONTEXT_MANAGEMENT = {
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"type": "clear_thinking_20251015",
|
||||||
|
"keep": "all", # Preserve all thinking blocks for cache reuse
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "clear_tool_uses_20250919",
|
||||||
|
"trigger": {"type": "input_tokens", "value": 80000},
|
||||||
|
"keep": {"type": "tool_uses", "value": 5},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -70,7 +90,6 @@ class AgentLoop:
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
model=self.model,
|
|
||||||
brave_api_key=brave_api_key,
|
brave_api_key=brave_api_key,
|
||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
@@ -83,35 +102,51 @@ class AgentLoop:
|
|||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
|
# Import native tools
|
||||||
|
from nanobot.agent.tools.anthropic import (
|
||||||
|
BashTool20250124,
|
||||||
|
EditTool20250728,
|
||||||
|
ComputerTool20251124,
|
||||||
|
)
|
||||||
|
|
||||||
# File tools (restrict to workspace if configured)
|
# File tools (restrict to workspace if configured)
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||||
self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||||
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
||||||
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
# Removed: replaced by EditTool20250728
|
||||||
|
# self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||||
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
||||||
|
|
||||||
# Shell tool
|
# Removed: replaced by BashTool20250124
|
||||||
self.tools.register(ExecTool(
|
# self.tools.register(ExecTool(
|
||||||
working_dir=str(self.workspace),
|
# working_dir=str(self.workspace),
|
||||||
timeout=self.exec_config.timeout,
|
# timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
# restrict_to_workspace=self.restrict_to_workspace,
|
||||||
))
|
# ))
|
||||||
|
|
||||||
# Web tools
|
# Web tools
|
||||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||||
self.tools.register(WebFetchTool())
|
self.tools.register(WebFetchTool())
|
||||||
|
|
||||||
# Message tool
|
# Message tool
|
||||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
message_tool = MessageTool(send_callback=self.bus.publish_outbound, sessions=self.sessions)
|
||||||
self.tools.register(message_tool)
|
self.tools.register(message_tool)
|
||||||
|
|
||||||
# Spawn tool (for subagents)
|
# Spawn tool (for subagents)
|
||||||
spawn_tool = SpawnTool(manager=self.subagents)
|
spawn_tool = SpawnTool(manager=self.subagents)
|
||||||
self.tools.register(spawn_tool)
|
self.tools.register(spawn_tool)
|
||||||
|
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
|
||||||
|
|
||||||
# Cron tool (for scheduling)
|
# Cron tool (for scheduling)
|
||||||
if self.cron_service:
|
if self.cron_service:
|
||||||
self.tools.register(CronTool(self.cron_service))
|
self.tools.register(CronTool(self.cron_service))
|
||||||
|
|
||||||
|
# Register native Anthropic tools
|
||||||
|
self.tools.register(BashTool20250124())
|
||||||
|
self.tools.register(EditTool20250728())
|
||||||
|
self.tools.register(ComputerTool20251124())
|
||||||
|
|
||||||
|
logger.info("Registered native Anthropic tools: bash, text_editor, computer")
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Run the agent loop, processing messages from the bus."""
|
"""Run the agent loop, processing messages from the bus."""
|
||||||
@@ -137,7 +172,8 @@ class AgentLoop:
|
|||||||
await self.bus.publish_outbound(OutboundMessage(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
content=f"Sorry, I encountered an error: {str(e)}"
|
content=f"Sorry, I encountered an error: {str(e)}",
|
||||||
|
metadata=msg.metadata or {},
|
||||||
))
|
))
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
@@ -156,7 +192,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
# Default models
|
# Default models
|
||||||
OPUS = "claude-opus-4-6"
|
OPUS = "claude-opus-4-6"
|
||||||
SONNET = "claude-sonnet-4-5"
|
SONNET = "claude-sonnet-4-6"
|
||||||
TOLERANCE = 1.17 # 17% overage triggers downgrade
|
TOLERANCE = 1.17 # 17% overage triggers downgrade
|
||||||
|
|
||||||
# Read rate limits
|
# Read rate limits
|
||||||
@@ -265,39 +301,74 @@ class AgentLoop:
|
|||||||
session.clear()
|
session.clear()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="🐈 New session started. Memory consolidated.")
|
content="🐈 New session started. Memory consolidated.",
|
||||||
|
metadata=msg.metadata or {})
|
||||||
if cmd == "/help":
|
if cmd == "/help":
|
||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
|
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status",
|
||||||
|
metadata=msg.metadata or {})
|
||||||
if cmd == "/quota":
|
if cmd == "/quota":
|
||||||
status = self._get_quota_status()
|
status = self._get_quota_status()
|
||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status,
|
||||||
|
metadata=msg.metadata or {})
|
||||||
|
|
||||||
# Consolidate memory before processing if session is too large
|
|
||||||
if len(session.messages) > self.memory_window:
|
|
||||||
await self._consolidate_memory(session)
|
|
||||||
|
|
||||||
# Update tool contexts
|
# Update tool contexts
|
||||||
message_tool = self.tools.get("message")
|
message_tool = self.tools.get("message")
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.set_context(msg.channel, msg.chat_id)
|
message_tool.set_context(msg.channel, msg.chat_id)
|
||||||
|
|
||||||
spawn_tool = self.tools.get("spawn")
|
spawn_tool = self.tools.get("spawn")
|
||||||
if isinstance(spawn_tool, SpawnTool):
|
if isinstance(spawn_tool, SpawnTool):
|
||||||
spawn_tool.set_context(msg.channel, msg.chat_id)
|
spawn_tool.set_context(msg.channel, msg.chat_id, msg.metadata)
|
||||||
|
|
||||||
cron_tool = self.tools.get("cron")
|
cron_tool = self.tools.get("cron")
|
||||||
if isinstance(cron_tool, CronTool):
|
if isinstance(cron_tool, CronTool):
|
||||||
cron_tool.set_context(msg.channel, msg.chat_id)
|
cron_tool.set_context(msg.channel, msg.chat_id)
|
||||||
|
|
||||||
|
# Track media for this turn (screenshots from computer tool)
|
||||||
|
media_paths_for_turn: list[str] = []
|
||||||
|
|
||||||
|
# Prepend current time + optional time-gap notice to every user message
|
||||||
|
now_dt = datetime.now()
|
||||||
|
tz = time.strftime("%Z") or "UTC"
|
||||||
|
time_str = now_dt.strftime("%Y-%m-%d %H:%M (%A)")
|
||||||
|
current_message = f"[Current time: {time_str} {tz}]\n{msg.content}"
|
||||||
|
|
||||||
|
# Prefix hook messages so the agent can identify them
|
||||||
|
hook_source = msg.metadata.get("hook_source") if msg.metadata else None
|
||||||
|
if hook_source:
|
||||||
|
current_message = f'[HOOK MESSAGE from "{hook_source}"]\n{current_message}'
|
||||||
|
|
||||||
|
last_user_ts = None
|
||||||
|
for m in reversed(session.messages):
|
||||||
|
if m.get("role") == "user":
|
||||||
|
last_user_ts = m.get("timestamp")
|
||||||
|
break
|
||||||
|
if last_user_ts:
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_user_ts)
|
||||||
|
elapsed_seconds = (now_dt - last_dt).total_seconds()
|
||||||
|
if elapsed_seconds > 300: # 5 minutes
|
||||||
|
if elapsed_seconds < 3600:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 60)} minutes"
|
||||||
|
elif elapsed_seconds < 86400:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 3600)} hours"
|
||||||
|
else:
|
||||||
|
gap_str = f"{int(elapsed_seconds // 86400)} days"
|
||||||
|
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user. Ask about it if appropriate]\n\n{current_message}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Malformed timestamp — skip silently
|
||||||
|
|
||||||
# Build initial messages (use get_history for LLM-formatted messages)
|
# Build initial messages (use get_history for LLM-formatted messages)
|
||||||
messages = self.context.build_messages(
|
messages = self.context.build_messages(
|
||||||
history=session.get_history(),
|
history=session.get_history(),
|
||||||
current_message=msg.content,
|
current_message=current_message,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
)
|
)
|
||||||
|
# Mark where the current turn starts so we can slice the tool chain for storage
|
||||||
|
turn_start = len(messages)
|
||||||
|
|
||||||
# Select model based on quota
|
# Select model based on quota
|
||||||
selected_model = self._select_model_based_on_quota()
|
selected_model = self._select_model_based_on_quota()
|
||||||
@@ -305,7 +376,7 @@ class AgentLoop:
|
|||||||
# Agent loop
|
# Agent loop
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
tools_used: list[str] = []
|
final_reasoning = None
|
||||||
|
|
||||||
while iteration < self.max_iterations:
|
while iteration < self.max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
@@ -314,10 +385,11 @@ class AgentLoop:
|
|||||||
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
|
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
|
||||||
response = await self.provider.chat(
|
response = await self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=self.tools.get_definitions(),
|
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||||
model=selected_model
|
model=selected_model,
|
||||||
|
context_management=self.CONTEXT_MANAGEMENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle tool calls
|
# Handle tool calls
|
||||||
if response.has_tool_calls:
|
if response.has_tool_calls:
|
||||||
# Add assistant message with tool calls
|
# Add assistant message with tool calls
|
||||||
@@ -336,44 +408,147 @@ class AgentLoop:
|
|||||||
messages, response.content, tool_call_dicts,
|
messages, response.content, tool_call_dicts,
|
||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute tools
|
# Execute tools
|
||||||
for tool_call in response.tool_calls:
|
for tool_call in response.tool_calls:
|
||||||
tools_used.append(tool_call.name)
|
|
||||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||||
|
|
||||||
|
# Handle different result types
|
||||||
|
if isinstance(result, ToolResult):
|
||||||
|
# Native Anthropic tool result
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
# Add text content
|
||||||
|
if result.output:
|
||||||
|
text_content = result.output
|
||||||
|
elif result.error:
|
||||||
|
text_content = f"Error: {result.error}"
|
||||||
|
else:
|
||||||
|
text_content = ""
|
||||||
|
|
||||||
|
# If both output and error, combine them
|
||||||
|
if result.output and result.error:
|
||||||
|
text_content = f"{result.output}\n\nError: {result.error}"
|
||||||
|
|
||||||
|
# If there's an image, use multipart content
|
||||||
|
if result.base64_image:
|
||||||
|
# Save screenshot to disk for user
|
||||||
|
import base64
|
||||||
|
media_dir = Path.home() / ".nanobot" / "media"
|
||||||
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
screenshot_path = media_dir / f"screenshot_{int(time.time() * 1000)}.png"
|
||||||
|
screenshot_path.write_bytes(base64.b64decode(result.base64_image))
|
||||||
|
media_paths_for_turn.append(str(screenshot_path))
|
||||||
|
logger.info(f"Saved screenshot to {screenshot_path}")
|
||||||
|
|
||||||
|
# Include in tool result for model to see
|
||||||
|
content_parts = [
|
||||||
|
{"type": "text", "text": text_content},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": result.base64_image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
tool_content = content_parts
|
||||||
|
else:
|
||||||
|
tool_content = text_content
|
||||||
|
|
||||||
|
elif isinstance(result, CLIResult):
|
||||||
|
# CLI-style tool result (text editor)
|
||||||
|
tool_content = result.output
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Legacy string result from function tools
|
||||||
|
tool_content = result
|
||||||
|
|
||||||
messages = self.context.add_tool_result(
|
messages = self.context.add_tool_result(
|
||||||
messages, tool_call.id, tool_call.name, result
|
messages, tool_call.id, tool_call.name, tool_content
|
||||||
)
|
)
|
||||||
# Interleaved CoT: reflect before next action
|
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
if not getattr(self.provider, 'thinking_budget', 0):
|
||||||
|
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||||
else:
|
else:
|
||||||
# No tool calls, we're done
|
# No tool calls
|
||||||
final_content = response.content
|
final_content = response.content
|
||||||
|
final_reasoning = response.reasoning_content
|
||||||
|
|
||||||
|
# Check for forged signatures if in suppress mode
|
||||||
|
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||||
|
if suppress_output and has_forged_marker(final_content):
|
||||||
|
# Initialize retry counter if needed
|
||||||
|
if not hasattr(self, '_forge_retry_count'):
|
||||||
|
self._forge_retry_count = 0
|
||||||
|
|
||||||
|
if self._forge_retry_count < 1:
|
||||||
|
# First offense: reject and retry with correction
|
||||||
|
self._forge_retry_count += 1
|
||||||
|
logger.warning("Model attempted to forge visibility marker, rejecting output")
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||||
|
})
|
||||||
|
continue # Back to while loop, will retry LLM call
|
||||||
|
else:
|
||||||
|
# Second offense: strip and log error (fallback)
|
||||||
|
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||||
|
final_content = strip_all_hidden_markers(final_content)
|
||||||
|
|
||||||
|
# Reset retry counter on successful completion
|
||||||
|
if hasattr(self, '_forge_retry_count'):
|
||||||
|
self._forge_retry_count = 0
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if final_content is None:
|
if final_content is None:
|
||||||
if iteration >= self.max_iterations:
|
if iteration >= self.max_iterations:
|
||||||
final_content = f"Reached {self.max_iterations} iterations without completion."
|
final_content = f"Reached {self.max_iterations} iterations without completion."
|
||||||
else:
|
else:
|
||||||
final_content = "I've completed processing but have no response to give."
|
final_content = "I've completed processing but have no response to give."
|
||||||
|
|
||||||
# Log response preview
|
# Log response preview
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||||
|
|
||||||
# Save to session (include tool names so consolidation sees what happened)
|
# Check for suppress mode BEFORE adding to session
|
||||||
session.add_message("user", msg.content)
|
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||||
session.add_message("assistant", final_content,
|
|
||||||
tools_used=tools_used if tools_used else None)
|
if suppress_output:
|
||||||
|
# Sign content with our secret key (forgery detection happens in loop above)
|
||||||
|
final_content_for_session = sign_content(final_content)
|
||||||
|
# Mark as suppressed for channel handler
|
||||||
|
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||||
|
else:
|
||||||
|
final_content_for_session = final_content
|
||||||
|
outbound_metadata = msg.metadata or {}
|
||||||
|
|
||||||
|
# Append final assistant response to messages so it's captured in the tool chain slice
|
||||||
|
# Use the prefixed version for session storage
|
||||||
|
messages = self.context.add_assistant_message(
|
||||||
|
messages, final_content_for_session, None,
|
||||||
|
reasoning_content=final_reasoning,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to session: user message + full tool chain (tool_use, tool_results, thinking, final reply)
|
||||||
|
# Store current_message (not msg.content) so the time prefix is preserved
|
||||||
|
# and cache keys match on subsequent turns
|
||||||
|
# Include sender_id to distinguish real user messages from system-generated ones
|
||||||
|
session.add_message("user", current_message, sender_id=msg.sender_id)
|
||||||
|
for chain_msg in messages[turn_start:]:
|
||||||
|
session.add_raw_message(chain_msg)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
content=final_content,
|
content=final_content_for_session,
|
||||||
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
|
metadata=outbound_metadata,
|
||||||
|
media=media_paths_for_turn if media_paths_for_turn else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
@@ -403,11 +578,12 @@ class AgentLoop:
|
|||||||
message_tool = self.tools.get("message")
|
message_tool = self.tools.get("message")
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.set_context(origin_channel, origin_chat_id)
|
message_tool.set_context(origin_channel, origin_chat_id)
|
||||||
|
|
||||||
|
|
||||||
spawn_tool = self.tools.get("spawn")
|
spawn_tool = self.tools.get("spawn")
|
||||||
if isinstance(spawn_tool, SpawnTool):
|
if isinstance(spawn_tool, SpawnTool):
|
||||||
spawn_tool.set_context(origin_channel, origin_chat_id)
|
spawn_tool.set_context(origin_channel, origin_chat_id, msg.metadata)
|
||||||
|
|
||||||
cron_tool = self.tools.get("cron")
|
cron_tool = self.tools.get("cron")
|
||||||
if isinstance(cron_tool, CronTool):
|
if isinstance(cron_tool, CronTool):
|
||||||
cron_tool.set_context(origin_channel, origin_chat_id)
|
cron_tool.set_context(origin_channel, origin_chat_id)
|
||||||
@@ -419,10 +595,12 @@ class AgentLoop:
|
|||||||
channel=origin_channel,
|
channel=origin_channel,
|
||||||
chat_id=origin_chat_id,
|
chat_id=origin_chat_id,
|
||||||
)
|
)
|
||||||
|
turn_start = len(messages)
|
||||||
|
|
||||||
# Agent loop (limited for announce handling)
|
# Agent loop (limited for announce handling)
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
|
final_reasoning = None
|
||||||
|
|
||||||
# Select model based on quota
|
# Select model based on quota
|
||||||
selected_model = self._select_model_based_on_quota()
|
selected_model = self._select_model_based_on_quota()
|
||||||
@@ -432,10 +610,11 @@ class AgentLoop:
|
|||||||
|
|
||||||
response = await self.provider.chat(
|
response = await self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=self.tools.get_definitions(),
|
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||||
model=selected_model
|
model=selected_model,
|
||||||
|
context_management=self.CONTEXT_MANAGEMENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.has_tool_calls:
|
if response.has_tool_calls:
|
||||||
tool_call_dicts = [
|
tool_call_dicts = [
|
||||||
{
|
{
|
||||||
@@ -452,36 +631,125 @@ class AgentLoop:
|
|||||||
messages, response.content, tool_call_dicts,
|
messages, response.content, tool_call_dicts,
|
||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
for tool_call in response.tool_calls:
|
for tool_call in response.tool_calls:
|
||||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||||
|
|
||||||
|
# Handle different result types (same logic as main handler)
|
||||||
|
if isinstance(result, ToolResult):
|
||||||
|
# Native Anthropic tool result
|
||||||
|
# Add text content
|
||||||
|
if result.output:
|
||||||
|
text_content = result.output
|
||||||
|
elif result.error:
|
||||||
|
text_content = f"Error: {result.error}"
|
||||||
|
else:
|
||||||
|
text_content = ""
|
||||||
|
|
||||||
|
# If both output and error, combine them
|
||||||
|
if result.output and result.error:
|
||||||
|
text_content = f"{result.output}\n\nError: {result.error}"
|
||||||
|
|
||||||
|
# Note: Image handling for system messages not needed
|
||||||
|
# (system messages don't render images to users)
|
||||||
|
# But we should still log if present
|
||||||
|
if result.base64_image:
|
||||||
|
logger.warning(
|
||||||
|
f"Tool {tool_call.name} returned image in system message context - "
|
||||||
|
"images not supported here"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_content = text_content
|
||||||
|
|
||||||
|
elif isinstance(result, CLIResult):
|
||||||
|
# CLI-style tool result (text editor)
|
||||||
|
tool_content = result.output
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Legacy string result from function tools
|
||||||
|
tool_content = result
|
||||||
|
|
||||||
messages = self.context.add_tool_result(
|
messages = self.context.add_tool_result(
|
||||||
messages, tool_call.id, tool_call.name, result
|
messages, tool_call.id, tool_call.name, tool_content
|
||||||
)
|
)
|
||||||
# Interleaved CoT: reflect before next action
|
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
if not getattr(self.provider, 'thinking_budget', 0):
|
||||||
|
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||||
else:
|
else:
|
||||||
|
# No tool calls
|
||||||
final_content = response.content
|
final_content = response.content
|
||||||
|
final_reasoning = response.reasoning_content
|
||||||
|
|
||||||
|
# Check for forged signatures if in suppress mode
|
||||||
|
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||||
|
if suppress_output and has_forged_marker(final_content):
|
||||||
|
# Initialize retry counter if needed
|
||||||
|
if not hasattr(self, '_forge_retry_count_system'):
|
||||||
|
self._forge_retry_count_system = 0
|
||||||
|
|
||||||
|
if self._forge_retry_count_system < 1:
|
||||||
|
# First offense: reject and retry with correction
|
||||||
|
self._forge_retry_count_system += 1
|
||||||
|
logger.warning("Model attempted to forge visibility marker in system message, rejecting output")
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||||
|
})
|
||||||
|
continue # Back to while loop, will retry LLM call
|
||||||
|
else:
|
||||||
|
# Second offense: strip and log error (fallback)
|
||||||
|
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||||
|
final_content = strip_all_hidden_markers(final_content)
|
||||||
|
|
||||||
|
# Reset retry counter on successful completion
|
||||||
|
if hasattr(self, '_forge_retry_count_system'):
|
||||||
|
self._forge_retry_count_system = 0
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if final_content is None:
|
if final_content is None:
|
||||||
final_content = "Background task completed."
|
final_content = "Background task completed."
|
||||||
|
|
||||||
# Save to session (mark as system message in history)
|
# Check for suppress mode BEFORE adding to session
|
||||||
|
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||||
|
|
||||||
|
if suppress_output:
|
||||||
|
# Sign content with our secret key (forgery detection happens in loop above)
|
||||||
|
final_content_for_session = sign_content(final_content)
|
||||||
|
# Mark as suppressed for channel handler
|
||||||
|
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||||
|
else:
|
||||||
|
final_content_for_session = final_content
|
||||||
|
outbound_metadata = msg.metadata or {}
|
||||||
|
|
||||||
|
# Append final assistant response to messages (use signed version for session)
|
||||||
|
messages = self.context.add_assistant_message(
|
||||||
|
messages, final_content_for_session, None,
|
||||||
|
reasoning_content=final_reasoning,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to session: user message + full tool chain
|
||||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||||
session.add_message("assistant", final_content)
|
for chain_msg in messages[turn_start:]:
|
||||||
|
session.add_raw_message(chain_msg)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
|
# Return original content (not signed) for outbound, but with suppressed metadata
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=origin_channel,
|
channel=origin_channel,
|
||||||
chat_id=origin_chat_id,
|
chat_id=origin_chat_id,
|
||||||
content=final_content
|
content=final_content,
|
||||||
|
metadata=outbound_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||||
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
|
"""Consolidate session into MEMORY.md + HISTORY.md.
|
||||||
|
|
||||||
|
Context window management is now handled server-side via context_management.
|
||||||
|
This only runs on /new to write long-term facts and searchable history.
|
||||||
|
"""
|
||||||
if not session.messages:
|
if not session.messages:
|
||||||
return
|
return
|
||||||
memory = MemoryStore(self.workspace)
|
memory = MemoryStore(self.workspace)
|
||||||
@@ -489,19 +757,50 @@ class AgentLoop:
|
|||||||
old_messages = session.messages
|
old_messages = session.messages
|
||||||
keep_count = 0
|
keep_count = 0
|
||||||
else:
|
else:
|
||||||
|
# Only write truly old messages; keep the recent ones
|
||||||
keep_count = min(10, max(2, self.memory_window // 2))
|
keep_count = min(10, max(2, self.memory_window // 2))
|
||||||
old_messages = session.messages[:-keep_count]
|
old_messages = session.messages[:-keep_count]
|
||||||
if not old_messages:
|
if not old_messages:
|
||||||
return
|
return
|
||||||
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
|
logger.info(f"Memory consolidation: archiving {len(old_messages)} messages, keeping {keep_count}")
|
||||||
|
|
||||||
# Format messages for LLM (include tool names when available)
|
# Format messages for LLM — handle full tool chain format
|
||||||
lines = []
|
lines = []
|
||||||
for m in old_messages:
|
for m in old_messages:
|
||||||
if not m.get("content"):
|
role = m.get("role", "?")
|
||||||
|
content = m.get("content")
|
||||||
|
timestamp = m.get("timestamp", "?")[:16]
|
||||||
|
|
||||||
|
if role == "tool":
|
||||||
|
result = str(content or "")[:200]
|
||||||
|
lines.append(f"[{timestamp}] TOOL_RESULT({m.get('name', '?')}): {result}")
|
||||||
continue
|
continue
|
||||||
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
|
|
||||||
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
|
# Skip internal reflect prompts
|
||||||
|
if role == "user" and content == "Reflect on the results and decide next steps.":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract text from content (may be list of blocks)
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||||
|
content_str = " ".join(text_parts)
|
||||||
|
elif isinstance(content, str):
|
||||||
|
content_str = content
|
||||||
|
else:
|
||||||
|
content_str = ""
|
||||||
|
|
||||||
|
# Get tool names from tool_calls or legacy tools_used
|
||||||
|
tool_names = []
|
||||||
|
if m.get("tool_calls"):
|
||||||
|
tool_names = [tc.get("function", {}).get("name", "?") for tc in m["tool_calls"]]
|
||||||
|
elif m.get("tools_used"):
|
||||||
|
tool_names = m["tools_used"]
|
||||||
|
|
||||||
|
if not content_str and not tool_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tools_str = f" [tools: {', '.join(tool_names)}]" if tool_names else ""
|
||||||
|
lines.append(f"[{timestamp}] {role.upper()}{tools_str}: {content_str}")
|
||||||
conversation = "\n".join(lines)
|
conversation = "\n".join(lines)
|
||||||
current_memory = memory.read_long_term()
|
current_memory = memory.read_long_term()
|
||||||
|
|
||||||
@@ -552,16 +851,18 @@ Respond with ONLY valid JSON, no markdown fences."""
|
|||||||
session_key: str = "cli:direct",
|
session_key: str = "cli:direct",
|
||||||
channel: str = "cli",
|
channel: str = "cli",
|
||||||
chat_id: str = "direct",
|
chat_id: str = "direct",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Process a message directly (for CLI or cron usage).
|
Process a message directly (for CLI or cron usage).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: The message content.
|
content: The message content.
|
||||||
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
||||||
channel: Source channel (for tool context routing).
|
channel: Source channel (for tool context routing).
|
||||||
chat_id: Source chat ID (for tool context routing).
|
chat_id: Source chat ID (for tool context routing).
|
||||||
|
metadata: Optional metadata to pass through (for suppress mode, etc.).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The agent's response.
|
The agent's response.
|
||||||
"""
|
"""
|
||||||
@@ -569,8 +870,9 @@ Respond with ONLY valid JSON, no markdown fences."""
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
sender_id="user",
|
sender_id="user",
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content
|
content=content,
|
||||||
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await self._process_message(msg, session_key=session_key)
|
response = await self._process_message(msg, session_key=session_key)
|
||||||
return response.content if response else ""
|
return response.content if response else ""
|
||||||
|
|||||||
+73
-25
@@ -15,6 +15,9 @@ from nanobot.agent.tools.registry import ToolRegistry
|
|||||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
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:
|
class SubagentManager:
|
||||||
@@ -40,11 +43,15 @@ class SubagentManager:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.model = model or provider.get_default_model()
|
# 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.brave_api_key = brave_api_key
|
||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
|
self._task_results: dict[str, str] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self,
|
||||||
@@ -53,25 +60,28 @@ class SubagentManager:
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
origin_channel: str = "cli",
|
origin_channel: str = "cli",
|
||||||
origin_chat_id: str = "direct",
|
origin_chat_id: str = "direct",
|
||||||
|
origin_metadata: dict[str, Any] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Spawn a subagent to execute a task in the background.
|
Spawn a subagent to execute a task in the background.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task: The task description for the subagent.
|
task: The task description for the subagent.
|
||||||
label: Optional human-readable label for the task.
|
label: Optional human-readable label for the task.
|
||||||
origin_channel: The channel to announce results to.
|
origin_channel: The channel to announce results to.
|
||||||
origin_chat_id: The chat ID 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:
|
Returns:
|
||||||
Status message indicating the subagent was started.
|
Status message indicating the subagent was started.
|
||||||
"""
|
"""
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||||
|
|
||||||
origin = {
|
origin = {
|
||||||
"channel": origin_channel,
|
"channel": origin_channel,
|
||||||
"chat_id": origin_chat_id,
|
"chat_id": origin_chat_id,
|
||||||
|
"metadata": origin_metadata or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create background task
|
# Create background task
|
||||||
@@ -84,7 +94,7 @@ class SubagentManager:
|
|||||||
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
||||||
|
|
||||||
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
||||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
return f"Subagent [{display_label}] started. Task ID: {task_id}"
|
||||||
|
|
||||||
async def _run_subagent(
|
async def _run_subagent(
|
||||||
self,
|
self,
|
||||||
@@ -98,7 +108,7 @@ class SubagentManager:
|
|||||||
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build subagent tools (no message tool, no spawn tool)
|
# Build subagent tools (no message tool)
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||||
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||||
@@ -112,7 +122,22 @@ class SubagentManager:
|
|||||||
))
|
))
|
||||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||||
tools.register(WebFetchTool())
|
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
|
# Build messages with subagent-specific prompt
|
||||||
system_prompt = self._build_subagent_prompt(task)
|
system_prompt = self._build_subagent_prompt(task)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
@@ -190,7 +215,16 @@ class SubagentManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Announce the subagent result to the main agent via the message bus."""
|
"""Announce the subagent result to the main agent via the message bus."""
|
||||||
status_text = "completed successfully" if status == "ok" else "failed"
|
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}]
|
announce_content = f"""[Subagent '{label}' {status_text}]
|
||||||
|
|
||||||
Task: {task}
|
Task: {task}
|
||||||
@@ -199,48 +233,43 @@ Result:
|
|||||||
{result}
|
{result}
|
||||||
|
|
||||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
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
|
# Inject as system message to trigger main agent
|
||||||
|
# Propagate metadata from origin (e.g. suppress_output)
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="system",
|
channel="system",
|
||||||
sender_id="subagent",
|
sender_id="subagent",
|
||||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||||
content=announce_content,
|
content=announce_content,
|
||||||
|
metadata=origin.get("metadata", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
logger.debug(f"Subagent [{task_id}] announced result to {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:
|
def _build_subagent_prompt(self, task: str) -> str:
|
||||||
"""Build a focused system prompt for the subagent."""
|
"""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
|
return f"""# Subagent
|
||||||
|
|
||||||
## Current Time
|
|
||||||
{now} ({tz})
|
|
||||||
|
|
||||||
You are a subagent spawned by the main agent to complete a specific task.
|
You are a subagent spawned by the main agent to complete a specific task.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
1. Stay focused - complete only the assigned task, nothing else
|
1. Run `exec date` as your very first action to get the current date and time
|
||||||
2. Your final response will be reported back to the main agent
|
2. Stay focused - complete only the assigned task, nothing else
|
||||||
3. Do not initiate conversations or take on side tasks
|
3. Your final response will be reported back to the main agent
|
||||||
4. Be concise but informative in your findings
|
4. Do not initiate conversations or take on side tasks
|
||||||
|
5. Be concise but informative in your findings
|
||||||
|
|
||||||
## What You Can Do
|
## What You Can Do
|
||||||
- Read and write files in the workspace
|
- Read and write files in the workspace
|
||||||
- Execute shell commands
|
- Execute shell commands
|
||||||
- Search the web and fetch web pages
|
- 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
|
- Complete the task thoroughly
|
||||||
|
|
||||||
## What You Cannot Do
|
## What You Cannot Do
|
||||||
- Send messages directly to users (no message tool available)
|
- Access the main agent's conversation history directly
|
||||||
- Spawn other subagents
|
|
||||||
- Access the main agent's conversation history
|
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: {self.workspace}
|
Your workspace is at: {self.workspace}
|
||||||
@@ -248,6 +277,25 @@ 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."""
|
When you have completed the task, provide a clear summary of your findings or actions."""
|
||||||
|
|
||||||
|
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:
|
def get_running_count(self) -> int:
|
||||||
"""Return the number of currently running subagents."""
|
"""Return the number of currently running subagents."""
|
||||||
return len(self._running_tasks)
|
return len(self._running_tasks)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseAnthropicTool",
|
||||||
|
"ToolResult",
|
||||||
|
"CLIResult",
|
||||||
|
"ToolError",
|
||||||
|
"BashTool20250124",
|
||||||
|
"EditTool20250728",
|
||||||
|
"ComputerTool20251124",
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -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"<<BASH_COMMAND_DONE_{uuid.uuid4().hex}>>"
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -4,18 +4,21 @@ from typing import Any, Callable, Awaitable
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.session import SessionManager
|
||||||
|
|
||||||
|
|
||||||
class MessageTool(Tool):
|
class MessageTool(Tool):
|
||||||
"""Tool to send messages to users on chat channels."""
|
"""Tool to send messages to users on chat channels."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||||
|
sessions: SessionManager | None = None,
|
||||||
default_channel: str = "",
|
default_channel: str = "",
|
||||||
default_chat_id: str = ""
|
default_chat_id: str = ""
|
||||||
):
|
):
|
||||||
self._send_callback = send_callback
|
self._send_callback = send_callback
|
||||||
|
self._sessions = sessions
|
||||||
self._default_channel = default_channel
|
self._default_channel = default_channel
|
||||||
self._default_chat_id = default_chat_id
|
self._default_chat_id = default_chat_id
|
||||||
|
|
||||||
@@ -45,6 +48,11 @@ class MessageTool(Tool):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The message content to send"
|
"description": "The message content to send"
|
||||||
},
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional: list of media file paths or URLs to attach"
|
||||||
|
},
|
||||||
"channel": {
|
"channel": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: target channel (telegram, discord, etc.)"
|
"description": "Optional: target channel (telegram, discord, etc.)"
|
||||||
@@ -58,29 +66,38 @@ class MessageTool(Tool):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
channel: str | None = None,
|
media: list[str] | None = None,
|
||||||
|
channel: str | None = None,
|
||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> str:
|
) -> str:
|
||||||
channel = channel or self._default_channel
|
channel = channel or self._default_channel
|
||||||
chat_id = chat_id or self._default_chat_id
|
chat_id = chat_id or self._default_chat_id
|
||||||
|
|
||||||
if not channel or not chat_id:
|
if not channel or not chat_id:
|
||||||
return "Error: No target channel/chat specified"
|
return "Error: No target channel/chat specified"
|
||||||
|
|
||||||
if not self._send_callback:
|
if not self._send_callback:
|
||||||
return "Error: Message sending not configured"
|
return "Error: Message sending not configured"
|
||||||
|
|
||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content
|
content=content,
|
||||||
|
media=media or []
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._send_callback(msg)
|
await self._send_callback(msg)
|
||||||
|
|
||||||
|
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}"
|
return f"Message sent to {channel}:{chat_id}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error sending message: {str(e)}"
|
return f"Error sending message: {str(e)}"
|
||||||
|
|||||||
@@ -32,20 +32,33 @@ class ToolRegistry:
|
|||||||
return name in self._tools
|
return name in self._tools
|
||||||
|
|
||||||
def get_definitions(self) -> list[dict[str, Any]]:
|
def get_definitions(self) -> list[dict[str, Any]]:
|
||||||
"""Get all tool definitions in OpenAI format."""
|
"""Get tool definitions for all registered tools.
|
||||||
return [tool.to_schema() for tool in self._tools.values()]
|
|
||||||
|
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]) -> str:
|
async def execute(self, name: str, params: dict[str, Any]) -> Any:
|
||||||
"""
|
"""
|
||||||
Execute a tool by name with given parameters.
|
Execute a tool by name with given parameters.
|
||||||
|
|
||||||
|
Supports both native Anthropic tools (via __call__) and function tools (via execute).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Tool name.
|
name: Tool name.
|
||||||
params: Tool parameters.
|
params: Tool parameters.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tool execution result as string.
|
Tool execution result (ToolResult, CLIResult, or string).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
KeyError: If tool not found.
|
KeyError: If tool not found.
|
||||||
"""
|
"""
|
||||||
@@ -54,20 +67,33 @@ class ToolRegistry:
|
|||||||
return f"Error: Tool '{name}' not found"
|
return f"Error: Tool '{name}' not found"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
errors = tool.validate_params(params)
|
# Duck typing - support both native and function tools
|
||||||
if errors:
|
if hasattr(tool, 'to_params'):
|
||||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
# Native Anthropic tool - call directly via __call__, no validation needed
|
||||||
return await tool.execute(**params)
|
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:
|
except Exception as e:
|
||||||
return f"Error executing {name}: {str(e)}"
|
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
|
@property
|
||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
"""Get list of registered tool names."""
|
"""Get list of registered tool names."""
|
||||||
return list(self._tools.keys())
|
return list(self._tools.keys())
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._tools)
|
return len(self._tools)
|
||||||
|
|
||||||
def __contains__(self, name: str) -> bool:
|
def __contains__(self, name: str) -> bool:
|
||||||
return name in self._tools
|
return name in self._tools
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ class SpawnTool(Tool):
|
|||||||
self._manager = manager
|
self._manager = manager
|
||||||
self._origin_channel = "cli"
|
self._origin_channel = "cli"
|
||||||
self._origin_chat_id = "direct"
|
self._origin_chat_id = "direct"
|
||||||
|
self._origin_metadata: dict[str, Any] = {}
|
||||||
def set_context(self, channel: str, chat_id: str) -> None:
|
|
||||||
|
def set_context(self, channel: str, chat_id: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
"""Set the origin context for subagent announcements."""
|
"""Set the origin context for subagent announcements."""
|
||||||
self._origin_channel = channel
|
self._origin_channel = channel
|
||||||
self._origin_chat_id = chat_id
|
self._origin_chat_id = chat_id
|
||||||
|
self._origin_metadata = metadata or {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -53,7 +55,7 @@ class SpawnTool(Tool):
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
|
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["task"],
|
"required": ["task"],
|
||||||
@@ -67,4 +69,5 @@ class SpawnTool(Tool):
|
|||||||
model=model,
|
model=model,
|
||||||
origin_channel=self._origin_channel,
|
origin_channel=self._origin_channel,
|
||||||
origin_chat_id=self._origin_chat_id,
|
origin_chat_id=self._origin_chat_id,
|
||||||
|
origin_metadata=self._origin_metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)}"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
+23
-1
@@ -20,6 +20,7 @@ class MessageBus:
|
|||||||
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||||
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
||||||
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
|
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
|
||||||
|
self._correlation_store: dict[str, asyncio.Future] = {}
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||||
@@ -37,7 +38,28 @@ class MessageBus:
|
|||||||
async def consume_outbound(self) -> OutboundMessage:
|
async def consume_outbound(self) -> OutboundMessage:
|
||||||
"""Consume the next outbound message (blocks until available)."""
|
"""Consume the next outbound message (blocks until available)."""
|
||||||
return await self.outbound.get()
|
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(
|
def subscribe_outbound(
|
||||||
self,
|
self,
|
||||||
channel: str,
|
channel: str,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -137,6 +137,11 @@ class ChannelManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"QQ channel not available: {e}")
|
logger.warning(f"QQ 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:
|
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||||
"""Start a channel and log any exceptions."""
|
"""Start a channel and log any exceptions."""
|
||||||
try:
|
try:
|
||||||
@@ -192,7 +197,10 @@ class ChannelManager:
|
|||||||
self.bus.consume_outbound(),
|
self.bus.consume_outbound(),
|
||||||
timeout=1.0
|
timeout=1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resolve any pending correlation (hook request-response)
|
||||||
|
self.bus.resolve_correlation(msg)
|
||||||
|
|
||||||
channel = self.channels.get(msg.channel)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from telegram import BotCommand, Update
|
from telegram import BotCommand, Update
|
||||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||||
@@ -185,20 +187,31 @@ class TelegramChannel(BaseChannel):
|
|||||||
if not self._app:
|
if not self._app:
|
||||||
logger.warning("Telegram bot not running")
|
logger.warning("Telegram bot not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Stop typing indicator for this chat
|
# Stop typing indicator for this chat
|
||||||
self._stop_typing(msg.chat_id)
|
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:
|
try:
|
||||||
# chat_id should be the Telegram chat ID (integer)
|
# chat_id should be the Telegram chat ID (integer)
|
||||||
chat_id = int(msg.chat_id)
|
chat_id = int(msg.chat_id)
|
||||||
# Convert markdown to Telegram HTML
|
# Convert markdown to Telegram HTML
|
||||||
html_content = _markdown_to_telegram_html(msg.content)
|
html_content = _markdown_to_telegram_html(msg.content)
|
||||||
await self._app.bot.send_message(
|
|
||||||
chat_id=chat_id,
|
# Check if message has media attachments
|
||||||
text=html_content,
|
if msg.media:
|
||||||
parse_mode="HTML"
|
await self._send_with_media(chat_id, html_content, msg.media)
|
||||||
)
|
else:
|
||||||
|
# Text-only message
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=html_content,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -211,7 +224,167 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"Error sending Telegram message: {e2}")
|
logger.error(f"Error sending Telegram message: {e2}")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Group media for album sending
|
||||||
|
media_items = [(path, kind) for path, kind, _, _ in processed_media]
|
||||||
|
grouping = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
media=album_media
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Handle /start command."""
|
"""Handle /start command."""
|
||||||
if not update.message or not update.effective_user:
|
if not update.message or not update.effective_user:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
+149
-90
@@ -2,23 +2,25 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
from pathlib import Path
|
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.patch_stdout import patch_stdout
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from nanobot import __logo__, __version__
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from nanobot.cli.oauth import oauth_app
|
||||||
from prompt_toolkit.history import FileHistory
|
|
||||||
from prompt_toolkit.patch_stdout import patch_stdout
|
|
||||||
|
|
||||||
from nanobot import __version__, __logo__
|
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="nanobot",
|
name="nanobot",
|
||||||
@@ -158,26 +160,26 @@ def onboard():
|
|||||||
from nanobot.config.loader import get_config_path, save_config
|
from nanobot.config.loader import get_config_path, save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
from nanobot.utils.helpers import get_workspace_path
|
||||||
|
|
||||||
config_path = get_config_path()
|
config_path = get_config_path()
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||||
if not typer.confirm("Overwrite?"):
|
if not typer.confirm("Overwrite?"):
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
# Create default config
|
# Create default config
|
||||||
config = Config()
|
config = Config()
|
||||||
save_config(config)
|
save_config(config)
|
||||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||||
|
|
||||||
# Create workspace
|
# Create workspace
|
||||||
workspace = get_workspace_path()
|
workspace = get_workspace_path()
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||||
|
|
||||||
# Create default bootstrap files
|
# Create default bootstrap files
|
||||||
_create_workspace_templates(workspace)
|
_create_workspace_templates(workspace)
|
||||||
|
|
||||||
console.print(f"\n{__logo__} nanobot is ready!")
|
console.print(f"\n{__logo__} nanobot is ready!")
|
||||||
console.print("\nNext steps:")
|
console.print("\nNext steps:")
|
||||||
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
||||||
@@ -229,13 +231,13 @@ Information about the user goes here.
|
|||||||
- Language: (your preferred language)
|
- Language: (your preferred language)
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
for filename, content in templates.items():
|
for filename, content in templates.items():
|
||||||
file_path = workspace / filename
|
file_path = workspace / filename
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
file_path.write_text(content)
|
file_path.write_text(content)
|
||||||
console.print(f" [dim]Created {filename}[/dim]")
|
console.print(f" [dim]Created {filename}[/dim]")
|
||||||
|
|
||||||
# Create memory directory and MEMORY.md
|
# Create memory directory and MEMORY.md
|
||||||
memory_dir = workspace / "memory"
|
memory_dir = workspace / "memory"
|
||||||
memory_dir.mkdir(exist_ok=True)
|
memory_dir.mkdir(exist_ok=True)
|
||||||
@@ -258,7 +260,7 @@ This file stores important information that should persist across sessions.
|
|||||||
(Things to remember)
|
(Things to remember)
|
||||||
""")
|
""")
|
||||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||||
|
|
||||||
history_file = memory_dir / "HISTORY.md"
|
history_file = memory_dir / "HISTORY.md"
|
||||||
if not history_file.exists():
|
if not history_file.exists():
|
||||||
history_file.write_text("")
|
history_file.write_text("")
|
||||||
@@ -293,36 +295,56 @@ def _make_provider(config):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _start_moltbook_loop():
|
||||||
|
"""Start the moltbook polling loop in the background."""
|
||||||
|
loop_script = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.sh"
|
||||||
|
log_file = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.log"
|
||||||
|
|
||||||
|
if not loop_script.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["/bin/bash", str(loop_script)],
|
||||||
|
stdout=open(log_file, "a"),
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
console.print(f"[green]✓[/green] Moltbook polling: every 15m")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[yellow]Warning: Could not start moltbook loop: {e}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def gateway(
|
def gateway(
|
||||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""Start the nanobot gateway."""
|
||||||
from nanobot.config.loader import load_config, get_data_dir
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.config.loader import get_data_dir, load_config
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
import logging
|
import logging
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Create cron service first (callback set after agent creation)
|
# Create cron service first (callback set after agent creation)
|
||||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop(
|
agent = AgentLoop(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
@@ -337,7 +359,7 @@ def gateway(
|
|||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
@@ -356,48 +378,84 @@ def gateway(
|
|||||||
))
|
))
|
||||||
return response
|
return response
|
||||||
cron.on_job = on_cron_job
|
cron.on_job = on_cron_job
|
||||||
|
|
||||||
# Create heartbeat service
|
# Create heartbeat service
|
||||||
async def on_heartbeat(prompt: str) -> str:
|
async def on_heartbeat(prompt: str, metadata: dict[str, Any] | None = None) -> str:
|
||||||
"""Execute heartbeat through the agent."""
|
"""Execute heartbeat through the agent."""
|
||||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
return await agent.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key="telegram:239824268", # Run in main telegram session
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="239824268",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
on_heartbeat=on_heartbeat,
|
on_heartbeat=on_heartbeat,
|
||||||
interval_s=30 * 60, # 30 minutes
|
interval_s=30 * 60, # 30 minutes
|
||||||
enabled=True
|
enabled=True,
|
||||||
|
session_manager=session_manager, # Pass session manager
|
||||||
|
target_session_key="telegram:239824268", # Target session
|
||||||
|
idle_threshold_s=20 * 60, # 20 minutes idle
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create channel manager
|
# Create channel manager
|
||||||
channels = ChannelManager(config, bus)
|
channels = ChannelManager(config, bus)
|
||||||
|
|
||||||
|
# Create hooks server
|
||||||
|
from nanobot.channels.hook import HookChannel
|
||||||
|
from nanobot.hooks.server import HooksServer
|
||||||
|
|
||||||
|
hooks_config = config.hooks if hasattr(config, 'hooks') else None
|
||||||
|
hooks_server = None
|
||||||
|
|
||||||
|
if hooks_config and hooks_config.enabled:
|
||||||
|
# Register hook channel
|
||||||
|
hook_channel = HookChannel(bus)
|
||||||
|
channels.register_channel("hook", hook_channel)
|
||||||
|
|
||||||
|
# Create hooks server (checks has_tokens internally)
|
||||||
|
hooks_server = HooksServer(
|
||||||
|
host=config.gateway.host,
|
||||||
|
port=config.gateway.port,
|
||||||
|
config=hooks_config,
|
||||||
|
bus=bus,
|
||||||
|
)
|
||||||
|
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
else:
|
else:
|
||||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||||
|
|
||||||
cron_status = cron.status()
|
cron_status = cron.status()
|
||||||
if cron_status["jobs"] > 0:
|
if cron_status["jobs"] > 0:
|
||||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
console.print("[green]✓[/green] Heartbeat: every 30m")
|
||||||
|
|
||||||
|
_start_moltbook_loop()
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
try:
|
try:
|
||||||
await cron.start()
|
await cron.start()
|
||||||
await heartbeat.start()
|
await heartbeat.start()
|
||||||
|
if hooks_server:
|
||||||
|
await hooks_server.start()
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
agent.run(),
|
agent.run(),
|
||||||
channels.start_all(),
|
channels.start_all(),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nShutting down...")
|
console.print("\nShutting down...")
|
||||||
|
if hooks_server:
|
||||||
|
await hooks_server.stop()
|
||||||
heartbeat.stop()
|
heartbeat.stop()
|
||||||
cron.stop()
|
cron.stop()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
await channels.stop_all()
|
await channels.stop_all()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
@@ -416,13 +474,14 @@ def agent(
|
|||||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||||
):
|
):
|
||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
|
|
||||||
@@ -430,7 +489,7 @@ def agent(
|
|||||||
logger.enable("nanobot")
|
logger.enable("nanobot")
|
||||||
else:
|
else:
|
||||||
logger.disable("nanobot")
|
logger.disable("nanobot")
|
||||||
|
|
||||||
agent_loop = AgentLoop(
|
agent_loop = AgentLoop(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -442,7 +501,7 @@ def agent(
|
|||||||
exec_config=config.tools.exec,
|
exec_config=config.tools.exec,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||||
def _thinking_ctx():
|
def _thinking_ctx():
|
||||||
if logs:
|
if logs:
|
||||||
@@ -457,7 +516,7 @@ def agent(
|
|||||||
with _thinking_ctx():
|
with _thinking_ctx():
|
||||||
response = await agent_loop.process_direct(message, session_id)
|
response = await agent_loop.process_direct(message, session_id)
|
||||||
_print_agent_response(response, render_markdown=markdown)
|
_print_agent_response(response, render_markdown=markdown)
|
||||||
|
|
||||||
asyncio.run(run_once())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
@@ -470,7 +529,7 @@ def agent(
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -484,7 +543,7 @@ def agent(
|
|||||||
_restore_terminal()
|
_restore_terminal()
|
||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
|
|
||||||
with _thinking_ctx():
|
with _thinking_ctx():
|
||||||
response = await agent_loop.process_direct(user_input, session_id)
|
response = await agent_loop.process_direct(user_input, session_id)
|
||||||
_print_agent_response(response, render_markdown=markdown)
|
_print_agent_response(response, render_markdown=markdown)
|
||||||
@@ -496,7 +555,7 @@ def agent(
|
|||||||
_restore_terminal()
|
_restore_terminal()
|
||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
|
|
||||||
asyncio.run(run_interactive())
|
asyncio.run(run_interactive())
|
||||||
|
|
||||||
|
|
||||||
@@ -508,7 +567,6 @@ def agent(
|
|||||||
channels_app = typer.Typer(help="Manage channels")
|
channels_app = typer.Typer(help="Manage channels")
|
||||||
app.add_typer(channels_app, name="channels")
|
app.add_typer(channels_app, name="channels")
|
||||||
|
|
||||||
from nanobot.cli.oauth import oauth_app
|
|
||||||
app.add_typer(oauth_app, name="oauth")
|
app.add_typer(oauth_app, name="oauth")
|
||||||
|
|
||||||
|
|
||||||
@@ -556,7 +614,7 @@ def channels_status():
|
|||||||
"✓" if mc.enabled else "✗",
|
"✓" if mc.enabled else "✗",
|
||||||
mc_base
|
mc_base
|
||||||
)
|
)
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
tg = config.channels.telegram
|
tg = config.channels.telegram
|
||||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||||
@@ -582,57 +640,57 @@ def _get_bridge_dir() -> Path:
|
|||||||
"""Get the bridge directory, setting it up if needed."""
|
"""Get the bridge directory, setting it up if needed."""
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# User's bridge location
|
# User's bridge location
|
||||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||||
|
|
||||||
# Check if already built
|
# Check if already built
|
||||||
if (user_bridge / "dist" / "index.js").exists():
|
if (user_bridge / "dist" / "index.js").exists():
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|
||||||
# Check for npm
|
# Check for npm
|
||||||
if not shutil.which("npm"):
|
if not shutil.which("npm"):
|
||||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Find source bridge: first check package data, then source dir
|
# Find source bridge: first check package data, then source dir
|
||||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||||
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
||||||
|
|
||||||
source = None
|
source = None
|
||||||
if (pkg_bridge / "package.json").exists():
|
if (pkg_bridge / "package.json").exists():
|
||||||
source = pkg_bridge
|
source = pkg_bridge
|
||||||
elif (src_bridge / "package.json").exists():
|
elif (src_bridge / "package.json").exists():
|
||||||
source = src_bridge
|
source = src_bridge
|
||||||
|
|
||||||
if not source:
|
if not source:
|
||||||
console.print("[red]Bridge source not found.[/red]")
|
console.print("[red]Bridge source not found.[/red]")
|
||||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print(f"{__logo__} Setting up bridge...")
|
console.print(f"{__logo__} Setting up bridge...")
|
||||||
|
|
||||||
# Copy to user directory
|
# Copy to user directory
|
||||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if user_bridge.exists():
|
if user_bridge.exists():
|
||||||
shutil.rmtree(user_bridge)
|
shutil.rmtree(user_bridge)
|
||||||
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||||
|
|
||||||
# Install and build
|
# Install and build
|
||||||
try:
|
try:
|
||||||
console.print(" Installing dependencies...")
|
console.print(" Installing dependencies...")
|
||||||
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
|
||||||
console.print(" Building...")
|
console.print(" Building...")
|
||||||
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
|
||||||
console.print("[green]✓[/green] Bridge ready\n")
|
console.print("[green]✓[/green] Bridge ready\n")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
console.print(f"[red]Build failed: {e}[/red]")
|
console.print(f"[red]Build failed: {e}[/red]")
|
||||||
if e.stderr:
|
if e.stderr:
|
||||||
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|
||||||
|
|
||||||
@@ -640,18 +698,19 @@ def _get_bridge_dir() -> Path:
|
|||||||
def channels_login():
|
def channels_login():
|
||||||
"""Link device via QR code."""
|
"""Link device via QR code."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
bridge_dir = _get_bridge_dir()
|
bridge_dir = _get_bridge_dir()
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting bridge...")
|
console.print(f"{__logo__} Starting bridge...")
|
||||||
console.print("Scan the QR code to connect.\n")
|
console.print("Scan the QR code to connect.\n")
|
||||||
|
|
||||||
env = {**os.environ}
|
env = {**os.environ}
|
||||||
if config.channels.whatsapp.bridge_token:
|
if config.channels.whatsapp.bridge_token:
|
||||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
@@ -675,23 +734,23 @@ def cron_list(
|
|||||||
"""List scheduled jobs."""
|
"""List scheduled jobs."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
jobs = service.list_jobs(include_disabled=all)
|
jobs = service.list_jobs(include_disabled=all)
|
||||||
|
|
||||||
if not jobs:
|
if not jobs:
|
||||||
console.print("No scheduled jobs.")
|
console.print("No scheduled jobs.")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table(title="Scheduled Jobs")
|
table = Table(title="Scheduled Jobs")
|
||||||
table.add_column("ID", style="cyan")
|
table.add_column("ID", style="cyan")
|
||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Schedule")
|
table.add_column("Schedule")
|
||||||
table.add_column("Status")
|
table.add_column("Status")
|
||||||
table.add_column("Next Run")
|
table.add_column("Next Run")
|
||||||
|
|
||||||
import time
|
import time
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
# Format schedule
|
# Format schedule
|
||||||
@@ -701,17 +760,17 @@ def cron_list(
|
|||||||
sched = job.schedule.expr or ""
|
sched = job.schedule.expr or ""
|
||||||
else:
|
else:
|
||||||
sched = "one-time"
|
sched = "one-time"
|
||||||
|
|
||||||
# Format next run
|
# Format next run
|
||||||
next_run = ""
|
next_run = ""
|
||||||
if job.state.next_run_at_ms:
|
if job.state.next_run_at_ms:
|
||||||
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
||||||
next_run = next_time
|
next_run = next_time
|
||||||
|
|
||||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||||
|
|
||||||
table.add_row(job.id, job.name, sched, status, next_run)
|
table.add_row(job.id, job.name, sched, status, next_run)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -730,7 +789,7 @@ def cron_add(
|
|||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
# Determine schedule type
|
# Determine schedule type
|
||||||
if every:
|
if every:
|
||||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
||||||
@@ -743,10 +802,10 @@ def cron_add(
|
|||||||
else:
|
else:
|
||||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
job = service.add_job(
|
job = service.add_job(
|
||||||
name=name,
|
name=name,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
@@ -755,7 +814,7 @@ def cron_add(
|
|||||||
to=to,
|
to=to,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||||
|
|
||||||
|
|
||||||
@@ -766,10 +825,10 @@ def cron_remove(
|
|||||||
"""Remove a scheduled job."""
|
"""Remove a scheduled job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
if service.remove_job(job_id):
|
if service.remove_job(job_id):
|
||||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||||
else:
|
else:
|
||||||
@@ -784,10 +843,10 @@ def cron_enable(
|
|||||||
"""Enable or disable a job."""
|
"""Enable or disable a job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
job = service.enable_job(job_id, enabled=not disable)
|
job = service.enable_job(job_id, enabled=not disable)
|
||||||
if job:
|
if job:
|
||||||
status = "disabled" if disable else "enabled"
|
status = "disabled" if disable else "enabled"
|
||||||
@@ -804,15 +863,15 @@ def cron_run(
|
|||||||
"""Manually run a job."""
|
"""Manually run a job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
return await service.run_job(job_id, force=force)
|
return await service.run_job(job_id, force=force)
|
||||||
|
|
||||||
if asyncio.run(run()):
|
if asyncio.run(run()):
|
||||||
console.print(f"[green]✓[/green] Job executed")
|
console.print("[green]✓[/green] Job executed")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||||
|
|
||||||
@@ -825,7 +884,7 @@ def cron_run(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def status():
|
def status():
|
||||||
"""Show nanobot status."""
|
"""Show nanobot status."""
|
||||||
from nanobot.config.loader import load_config, get_config_path
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
|
|
||||||
config_path = get_config_path()
|
config_path = get_config_path()
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -840,7 +899,7 @@ def status():
|
|||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
console.print(f"Model: {config.agents.defaults.model}")
|
console.print(f"Model: {config.agents.defaults.model}")
|
||||||
|
|
||||||
# Check API keys from registry
|
# Check API keys from registry
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(config.providers, spec.name, None)
|
p = getattr(config.providers, spec.name, None)
|
||||||
|
|||||||
@@ -230,6 +230,26 @@ class GatewayConfig(BaseModel):
|
|||||||
port: int = 18790
|
port: int = 18790
|
||||||
|
|
||||||
|
|
||||||
|
class HooksConfig(BaseModel):
|
||||||
|
"""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(BaseModel):
|
class WebSearchConfig(BaseModel):
|
||||||
"""Web search tool configuration."""
|
"""Web search tool configuration."""
|
||||||
api_key: str = "" # Brave Search API key
|
api_key: str = "" # Brave Search API key
|
||||||
@@ -259,6 +279,7 @@ class Config(BaseSettings):
|
|||||||
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
||||||
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
||||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
|
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Coroutine
|
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
# Default interval: 30 minutes
|
# Default interval: 30 minutes
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
||||||
|
|
||||||
@@ -22,45 +27,51 @@ def _is_heartbeat_empty(content: str | None) -> bool:
|
|||||||
"""Check if HEARTBEAT.md has no actionable content."""
|
"""Check if HEARTBEAT.md has no actionable content."""
|
||||||
if not content:
|
if not content:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Lines to skip: empty, headers, HTML comments, empty checkboxes
|
# Lines to skip: empty, headers, HTML comments, empty checkboxes
|
||||||
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
|
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
|
||||||
|
|
||||||
for line in content.split("\n"):
|
for line in content.split("\n"):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
|
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
|
||||||
continue
|
continue
|
||||||
return False # Found actionable content
|
return False # Found actionable content
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatService:
|
class HeartbeatService:
|
||||||
"""
|
"""
|
||||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
Periodic heartbeat service that wakes the agent to check for tasks.
|
||||||
|
|
||||||
The agent reads HEARTBEAT.md from the workspace and executes any
|
The agent reads HEARTBEAT.md from the workspace and executes any
|
||||||
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
|
||||||
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
|
session_manager: SessionManager | None = None,
|
||||||
|
target_session_key: str = "telegram:239824268",
|
||||||
|
idle_threshold_s: int = 30 * 60, # 30 minutes
|
||||||
):
|
):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.on_heartbeat = on_heartbeat
|
self.on_heartbeat = on_heartbeat
|
||||||
self.interval_s = interval_s
|
self.interval_s = interval_s
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
self.session_manager = session_manager
|
||||||
|
self.target_session_key = target_session_key
|
||||||
|
self.idle_threshold_s = idle_threshold_s
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def heartbeat_file(self) -> Path:
|
def heartbeat_file(self) -> Path:
|
||||||
return self.workspace / "HEARTBEAT.md"
|
return self.workspace / "HEARTBEAT.md"
|
||||||
|
|
||||||
def _read_heartbeat_file(self) -> str | None:
|
def _read_heartbeat_file(self) -> str | None:
|
||||||
"""Read HEARTBEAT.md content."""
|
"""Read HEARTBEAT.md content."""
|
||||||
if self.heartbeat_file.exists():
|
if self.heartbeat_file.exists():
|
||||||
@@ -69,24 +80,24 @@ class HeartbeatService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the heartbeat service."""
|
"""Start the heartbeat service."""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
logger.info("Heartbeat disabled")
|
logger.info("Heartbeat disabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._task = asyncio.create_task(self._run_loop())
|
self._task = asyncio.create_task(self._run_loop())
|
||||||
logger.info(f"Heartbeat started (every {self.interval_s}s)")
|
logger.info(f"Heartbeat started (every {self.interval_s}s)")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop the heartbeat service."""
|
"""Stop the heartbeat service."""
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._task:
|
if self._task:
|
||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
self._task = None
|
self._task = None
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
async def _run_loop(self) -> None:
|
||||||
"""Main heartbeat loop."""
|
"""Main heartbeat loop."""
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -98,33 +109,68 @@ class HeartbeatService:
|
|||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Heartbeat error: {e}")
|
logger.error(f"Heartbeat error: {e}")
|
||||||
|
|
||||||
async def _tick(self) -> None:
|
async def _tick(self) -> None:
|
||||||
"""Execute a single heartbeat tick."""
|
"""Execute a single heartbeat tick."""
|
||||||
|
|
||||||
|
# Check if user is idle (if session manager provided)
|
||||||
|
if self.session_manager and self.target_session_key:
|
||||||
|
try:
|
||||||
|
session = self.session_manager.get_or_create(self.target_session_key)
|
||||||
|
|
||||||
|
# Find last real user message timestamp (exclude system-generated messages)
|
||||||
|
# Real Telegram messages have sender_id like "239824268|username"
|
||||||
|
# System messages (heartbeat, cron) created via process_direct have sender_id="user"
|
||||||
|
# Old messages may not have sender_id field (backwards compat: treat as real user messages)
|
||||||
|
last_user_timestamp = None
|
||||||
|
for msg in reversed(session.messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
sender_id = msg.get("sender_id")
|
||||||
|
# Skip if explicitly marked as system-generated
|
||||||
|
if sender_id == "user":
|
||||||
|
continue
|
||||||
|
# Accept if no sender_id (old message) or if real user ID
|
||||||
|
last_user_timestamp = msg.get("timestamp")
|
||||||
|
break
|
||||||
|
|
||||||
|
if last_user_timestamp:
|
||||||
|
from datetime import datetime
|
||||||
|
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||||
|
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||||
|
|
||||||
|
if elapsed < self.idle_threshold_s:
|
||||||
|
logger.debug(f"Heartbeat: user active {int(elapsed)}s ago, skipping")
|
||||||
|
return # User is active, don't trigger heartbeat
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Heartbeat: error checking idle state: {e}")
|
||||||
|
# Continue with heartbeat on error (fail open)
|
||||||
|
|
||||||
|
# Original heartbeat logic
|
||||||
content = self._read_heartbeat_file()
|
content = self._read_heartbeat_file()
|
||||||
|
|
||||||
# Skip if HEARTBEAT.md is empty or doesn't exist
|
# Skip if HEARTBEAT.md is empty or doesn't exist
|
||||||
if _is_heartbeat_empty(content):
|
if _is_heartbeat_empty(content):
|
||||||
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
|
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Heartbeat: checking for tasks...")
|
logger.info("Heartbeat: user idle, checking for tasks...")
|
||||||
|
|
||||||
if self.on_heartbeat:
|
if self.on_heartbeat:
|
||||||
try:
|
try:
|
||||||
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
# Call with suppress_output metadata
|
||||||
|
await self.on_heartbeat(
|
||||||
# Check if agent said "nothing to do"
|
HEARTBEAT_PROMPT,
|
||||||
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
|
metadata={"suppress_output": True}
|
||||||
logger.info("Heartbeat: OK (no action needed)")
|
)
|
||||||
else:
|
|
||||||
logger.info(f"Heartbeat: completed task")
|
# Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
|
||||||
|
logger.info("Heartbeat: completed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Heartbeat execution failed: {e}")
|
logger.error(f"Heartbeat execution failed: {e}")
|
||||||
|
|
||||||
async def trigger_now(self) -> str | None:
|
async def trigger_now(self) -> str | None:
|
||||||
"""Manually trigger a heartbeat."""
|
"""Manually trigger a heartbeat."""
|
||||||
if self.on_heartbeat:
|
if self.on_heartbeat:
|
||||||
return await self.on_heartbeat(HEARTBEAT_PROMPT)
|
return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""HTTP hooks server for external service integration."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.config.schema import HooksConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HooksServer:
|
||||||
|
"""
|
||||||
|
HTTP server exposing a /hooks endpoint.
|
||||||
|
|
||||||
|
External services POST JSON messages. The server publishes them
|
||||||
|
to the bus as InboundMessages and uses bus-level correlation
|
||||||
|
to return the agent's response synchronously.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
config: HooksConfig,
|
||||||
|
bus: MessageBus,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.config = config
|
||||||
|
self.bus = bus
|
||||||
|
self._app = web.Application()
|
||||||
|
self._app.router.add_post(self.config.path, self._handle_hook)
|
||||||
|
self._app.router.add_get("/health", self._handle_health)
|
||||||
|
self._runner: web.AppRunner | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the HTTP server."""
|
||||||
|
if not self.config.has_tokens:
|
||||||
|
logger.warning("Hooks server has no tokens configured — endpoint disabled for security")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._runner = web.AppRunner(self._app)
|
||||||
|
await self._runner.setup()
|
||||||
|
site = web.TCPSite(self._runner, self.host, self.port)
|
||||||
|
await site.start()
|
||||||
|
logger.info(f"Hooks server listening on {self.host}:{self.port}{self.config.path}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the HTTP server."""
|
||||||
|
if self._runner:
|
||||||
|
await self._runner.cleanup()
|
||||||
|
self._runner = None
|
||||||
|
|
||||||
|
def _resolve_auth(self, request: web.Request) -> str | None:
|
||||||
|
"""
|
||||||
|
Validate auth and return token name if valid, None otherwise.
|
||||||
|
Checks Authorization: Bearer <token> and X-Hook-Token headers.
|
||||||
|
"""
|
||||||
|
# Try Authorization: Bearer <token>
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
token = auth[7:]
|
||||||
|
else:
|
||||||
|
# Try X-Hook-Token header
|
||||||
|
token = request.headers.get("X-Hook-Token", "")
|
||||||
|
|
||||||
|
return self.config.resolve_token(token) if token else None
|
||||||
|
|
||||||
|
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||||
|
"""Health check endpoint — no auth required."""
|
||||||
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
|
async def _handle_hook(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle incoming hook request."""
|
||||||
|
# Auth check — resolve token name
|
||||||
|
token_name = self._resolve_auth(request)
|
||||||
|
if not token_name:
|
||||||
|
return web.json_response({"error": "unauthorized"}, status=401)
|
||||||
|
|
||||||
|
# Parse body
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except (json.JSONDecodeError, Exception):
|
||||||
|
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
message = body.get("message")
|
||||||
|
if not message or not isinstance(message, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "missing or invalid 'message' field"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional fields
|
||||||
|
channel = body.get("channel", "hook")
|
||||||
|
chat_id = body.get("chat_id", token_name)
|
||||||
|
timeout = body.get("timeout", self.config.timeout_seconds)
|
||||||
|
|
||||||
|
# Create correlation
|
||||||
|
correlation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Build InboundMessage
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel=channel,
|
||||||
|
sender_id=f"hook:{token_name}",
|
||||||
|
chat_id=str(chat_id),
|
||||||
|
content=message,
|
||||||
|
metadata={
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"hook_source": token_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fire-and-forget mode
|
||||||
|
if timeout == 0:
|
||||||
|
await self.bus.publish_inbound(msg)
|
||||||
|
return web.json_response({"ok": True}, status=202)
|
||||||
|
|
||||||
|
# Request-response mode
|
||||||
|
future = self.bus.register_correlation(correlation_id)
|
||||||
|
await self.bus.publish_inbound(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(future, timeout=timeout)
|
||||||
|
return web.json_response({"ok": True, "response": response})
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "error": f"agent did not respond within {timeout}s"},
|
||||||
|
status=504,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Hook processing error: {e}")
|
||||||
|
return web.json_response({"error": "internal error"}, status=500)
|
||||||
|
finally:
|
||||||
|
# Clean up correlation on any failure
|
||||||
|
self.bus.cancel_correlation(correlation_id)
|
||||||
@@ -199,21 +199,41 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
|
|
||||||
def _convert_tools_to_anthropic(
|
def _convert_tools_to_anthropic(
|
||||||
self,
|
self,
|
||||||
tools: list[dict[str, Any]] | None
|
tools: list[dict[str, Any]] | list[Any] | None
|
||||||
) -> list[dict[str, Any]] | None:
|
) -> list[dict[str, Any]] | None:
|
||||||
"""Convert OpenAI-format tools to Anthropic format."""
|
"""Convert tools to Anthropic API format.
|
||||||
|
|
||||||
|
Supports both function tools (custom) and native tools (Anthropic).
|
||||||
|
Function tools are converted to Anthropic format.
|
||||||
|
Native tools are passed through unchanged.
|
||||||
|
Tool objects (with to_params/to_schema methods) are converted to dicts.
|
||||||
|
"""
|
||||||
if not tools:
|
if not tools:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
anthropic_tools = []
|
anthropic_tools = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.get("type") == "function":
|
# Convert tool objects to dicts first
|
||||||
func = tool["function"]
|
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||||
|
tool_dict = tool.to_params()
|
||||||
|
elif hasattr(tool, 'to_schema'): # Function tool
|
||||||
|
tool_dict = tool.to_schema()
|
||||||
|
else:
|
||||||
|
tool_dict = tool # Already a dict
|
||||||
|
|
||||||
|
# Now process the dict
|
||||||
|
if tool_dict.get("type") == "function":
|
||||||
|
# Convert function tool format
|
||||||
|
func = tool_dict["function"]
|
||||||
anthropic_tools.append({
|
anthropic_tools.append({
|
||||||
"name": func["name"],
|
"name": func["name"],
|
||||||
"description": func.get("description", ""),
|
"description": func.get("description", ""),
|
||||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
# Pass through native tool format as-is
|
||||||
|
# (bash_20250124, text_editor_20250728, computer_20251124, etc.)
|
||||||
|
anthropic_tools.append(tool_dict)
|
||||||
|
|
||||||
return anthropic_tools if anthropic_tools else None
|
return anthropic_tools if anthropic_tools else None
|
||||||
|
|
||||||
@@ -226,10 +246,25 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
thinking_budget_override: int | None = None,
|
thinking_budget_override: int | None = None,
|
||||||
|
context_management: dict[str, Any] | None = None,
|
||||||
|
beta_flags: set[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Make request to Anthropic API."""
|
"""Make request to Anthropic API."""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# Cache the last user message so conversation history is cached across turns
|
||||||
|
if messages:
|
||||||
|
last = messages[-1]
|
||||||
|
if last.get("role") == "user":
|
||||||
|
content = last["content"]
|
||||||
|
if isinstance(content, str):
|
||||||
|
last = {**last, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||||
|
elif isinstance(content, list) and content:
|
||||||
|
new_content = list(content)
|
||||||
|
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||||
|
last = {**last, "content": new_content}
|
||||||
|
messages = messages[:-1] + [last]
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
@@ -251,24 +286,75 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
payload["temperature"] = temperature
|
payload["temperature"] = temperature
|
||||||
|
|
||||||
if system:
|
if system:
|
||||||
payload["system"] = system
|
payload["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
cached_tools = list(tools)
|
||||||
|
cached_tools[-1] = {**cached_tools[-1], "cache_control": {"type": "ephemeral", "ttl": "1h"}}
|
||||||
|
payload["tools"] = cached_tools
|
||||||
|
|
||||||
|
if context_management:
|
||||||
|
payload["context_management"] = context_management
|
||||||
|
|
||||||
|
edit_types = [e.get("type") for e in (context_management or {}).get("edits", [])]
|
||||||
|
|
||||||
|
# Build headers with beta flags if provided
|
||||||
|
headers = self._get_headers()
|
||||||
|
if beta_flags:
|
||||||
|
# Merge with existing beta header (from OAuth hardcoded flags)
|
||||||
|
existing_beta = headers.get("anthropic-beta", "")
|
||||||
|
existing_flags = set(existing_beta.split(",")) if existing_beta else set()
|
||||||
|
all_flags = existing_flags | beta_flags
|
||||||
|
headers["anthropic-beta"] = ",".join(sorted(all_flags))
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Anthropic request: model={} max_tokens={} thinking={} tools={}",
|
"Anthropic request: model={} max_tokens={} thinking={} tools={} context_mgmt={} beta={}",
|
||||||
payload.get("model"), payload.get("max_tokens"),
|
payload.get("model"), payload.get("max_tokens"),
|
||||||
payload.get("thinking", "disabled"),
|
payload.get("thinking", "disabled"),
|
||||||
len(payload.get("tools", [])),
|
len(payload.get("tools", [])),
|
||||||
|
edit_types or "none",
|
||||||
|
headers.get("anthropic-beta", "none"),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
self._get_api_url(),
|
self._get_api_url(),
|
||||||
headers=self._get_headers(),
|
headers=headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Dump rate limit headers for analysis
|
||||||
|
try:
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
header_dump = {
|
||||||
|
"timestamp": datetime.datetime.utcnow().isoformat(),
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"model": payload.get("model"),
|
||||||
|
"headers": dict(response.headers),
|
||||||
|
}
|
||||||
|
dump_path = "/root/.nanobot/workspace/api_headers.jsonl"
|
||||||
|
with open(dump_path, "a") as f:
|
||||||
|
f.write(json.dumps(header_dump) + "\n")
|
||||||
|
# Capture rate limit state for quota-based model switching
|
||||||
|
hdrs = response.headers
|
||||||
|
rate_limit_state = {
|
||||||
|
"updated_at": datetime.datetime.utcnow().isoformat(),
|
||||||
|
"model": payload.get("model"),
|
||||||
|
"weekly_all_models": float(hdrs["anthropic-ratelimit-unified-7d-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d-utilization") else None,
|
||||||
|
"weekly_sonnet": float(hdrs["anthropic-ratelimit-unified-7d_sonnet-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d_sonnet-utilization") else None,
|
||||||
|
"session_5h": float(hdrs["anthropic-ratelimit-unified-5h-utilization"]) if hdrs.get("anthropic-ratelimit-unified-5h-utilization") else None,
|
||||||
|
"weekly_reset": int(hdrs["anthropic-ratelimit-unified-7d-reset"]) if hdrs.get("anthropic-ratelimit-unified-7d-reset") else None,
|
||||||
|
"session_reset": int(hdrs["anthropic-ratelimit-unified-5h-reset"]) if hdrs.get("anthropic-ratelimit-unified-5h-reset") else None,
|
||||||
|
"binding_limit": hdrs.get("anthropic-ratelimit-unified-representative-claim"),
|
||||||
|
"sonnet_fallback": hdrs.get("anthropic-ratelimit-unified-fallback"),
|
||||||
|
}
|
||||||
|
state_path = "/root/.nanobot/workspace/memory/rate_limits.json"
|
||||||
|
os.makedirs(os.path.dirname(state_path), exist_ok=True)
|
||||||
|
with open(state_path, "w") as f:
|
||||||
|
json.dump(rate_limit_state, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Rate limit header capture failed: {}", e)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
error_text = response.text
|
error_text = response.text
|
||||||
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
|
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
|
||||||
@@ -278,11 +364,12 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | list[Any] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_budget: int | None = None,
|
thinking_budget: int | None = None,
|
||||||
|
context_management: dict[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Send chat completion request to Anthropic API."""
|
"""Send chat completion request to Anthropic API."""
|
||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
@@ -295,6 +382,17 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
model = self._normalize_model(model)
|
model = self._normalize_model(model)
|
||||||
|
|
||||||
system, prepared_messages = self._prepare_messages(messages)
|
system, prepared_messages = self._prepare_messages(messages)
|
||||||
|
|
||||||
|
# Collect beta flags from native tools BEFORE conversion
|
||||||
|
beta_flags: set[str] = set()
|
||||||
|
if tools:
|
||||||
|
for tool in tools:
|
||||||
|
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||||
|
beta_flags.add(tool.beta_flag)
|
||||||
|
|
||||||
|
logger.debug(f"Beta flags collected: {beta_flags} (from {len(tools) if tools else 0} tools)")
|
||||||
|
|
||||||
|
# Convert tools to API format
|
||||||
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
||||||
|
|
||||||
# Per-call thinking override (None = use instance default)
|
# Per-call thinking override (None = use instance default)
|
||||||
@@ -309,6 +407,8 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
tools=anthropic_tools,
|
tools=anthropic_tools,
|
||||||
thinking_budget_override=effective_thinking,
|
thinking_budget_override=effective_thinking,
|
||||||
|
context_management=context_management,
|
||||||
|
beta_flags=beta_flags,
|
||||||
)
|
)
|
||||||
return self._parse_response(response)
|
return self._parse_response(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -351,13 +451,33 @@ class AnthropicOAuthProvider(LLMProvider):
|
|||||||
|
|
||||||
stop_reason = response.get("stop_reason", "end_turn")
|
stop_reason = response.get("stop_reason", "end_turn")
|
||||||
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
|
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
|
||||||
|
raw_usage = response.get("usage", {})
|
||||||
|
cache_write = raw_usage.get("cache_creation_input_tokens", 0)
|
||||||
|
cache_read = raw_usage.get("cache_read_input_tokens", 0)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
|
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
|
||||||
"input={} output={} tokens",
|
"input={} output={} cache_write={} cache_read={} tokens",
|
||||||
stop_reason, len(tool_calls), thinking_chars,
|
stop_reason, len(tool_calls), thinking_chars,
|
||||||
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
||||||
|
cache_write, cache_read,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log context editing activity if any edits were applied
|
||||||
|
if applied_edits := response.get("context_management", {}).get("applied_edits"):
|
||||||
|
for edit in applied_edits:
|
||||||
|
edit_type = edit.get("type", "?")
|
||||||
|
cleared_tokens = edit.get("cleared_input_tokens", 0)
|
||||||
|
if edit_type == "clear_tool_uses_20250919":
|
||||||
|
logger.info(
|
||||||
|
"Context edit: cleared {} tool uses ({} tokens)",
|
||||||
|
edit.get("cleared_tool_uses", 0), cleared_tokens,
|
||||||
|
)
|
||||||
|
elif edit_type == "clear_thinking_20251015":
|
||||||
|
logger.info(
|
||||||
|
"Context edit: cleared {} thinking turns ({} tokens)",
|
||||||
|
edit.get("cleared_thinking_turns", 0), cleared_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=text_content or None,
|
content=text_content or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class LLMProvider(ABC):
|
|||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_budget: int | None = None,
|
thinking_budget: int | None = None,
|
||||||
|
context_management: dict[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""
|
"""
|
||||||
Send a chat completion request.
|
Send a chat completion request.
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_budget: int | None = None,
|
thinking_budget: int | None = None,
|
||||||
|
context_management: dict[str, Any] | None = None, # Anthropic-only, ignored here
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""
|
"""
|
||||||
Send a chat completion request via LiteLLM.
|
Send a chat completion request via LiteLLM.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
|
|||||||
if is_oauth:
|
if is_oauth:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
# Required headers to mimic Claude Code client
|
# Required headers to mimic Claude Code client
|
||||||
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20"
|
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27"
|
||||||
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
||||||
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
|
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
|
||||||
headers["x-app"] = "cli"
|
headers["x-app"] = "cli"
|
||||||
|
|||||||
+25
-13
@@ -35,22 +35,34 @@ class Session:
|
|||||||
}
|
}
|
||||||
self.messages.append(msg)
|
self.messages.append(msg)
|
||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
|
|
||||||
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
|
def add_raw_message(self, msg: dict[str, Any]) -> None:
|
||||||
|
"""Add a pre-formed message dict to the session, preserving all fields."""
|
||||||
|
stored = dict(msg)
|
||||||
|
if "timestamp" not in stored:
|
||||||
|
stored["timestamp"] = datetime.now().isoformat()
|
||||||
|
self.messages.append(stored)
|
||||||
|
self.updated_at = datetime.now()
|
||||||
|
|
||||||
|
# Fields that are valid in the Anthropic/OpenAI messages API.
|
||||||
|
# Everything else (timestamp, tools_used, etc.) is internal metadata.
|
||||||
|
_API_FIELDS = {"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}
|
||||||
|
|
||||||
|
def get_history(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get message history for LLM context.
|
Get full message history for LLM context.
|
||||||
|
|
||||||
Args:
|
The server-side context editing API (clear_tool_uses_20250919) handles
|
||||||
max_messages: Maximum messages to return.
|
trimming old tool chains safely at token thresholds, so we send the full
|
||||||
|
history and let the server decide what to drop.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of messages in LLM format.
|
List of messages in LLM format (API-relevant fields only).
|
||||||
"""
|
"""
|
||||||
# Get recent messages
|
return [
|
||||||
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
|
{k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
|
||||||
|
for m in self.messages
|
||||||
# Convert to LLM format (just role and content)
|
]
|
||||||
return [{"role": m["role"], "content": m["content"]} for m in recent]
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all messages in the session."""
|
"""Clear all messages in the session."""
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies = [
|
|||||||
"qq-botpy>=1.0.0",
|
"qq-botpy>=1.0.0",
|
||||||
"python-socks[asyncio]>=2.4.0",
|
"python-socks[asyncio]>=2.4.0",
|
||||||
"prompt-toolkit>=3.0.0",
|
"prompt-toolkit>=3.0.0",
|
||||||
|
"vncdotool>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# tests/test_agent_loop_metadata.py
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_direct_passes_metadata():
|
||||||
|
"""Test that process_direct passes metadata to InboundMessage."""
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="test response",
|
||||||
|
tool_calls=[]
|
||||||
|
))
|
||||||
|
provider.get_default_model = MagicMock(return_value="test-model")
|
||||||
|
provider.thinking_budget = 0
|
||||||
|
|
||||||
|
workspace = Path("/tmp/test-workspace")
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||||
|
|
||||||
|
# Call with metadata
|
||||||
|
test_metadata = {"suppress_output": True, "test_key": "test_value"}
|
||||||
|
await loop.process_direct(
|
||||||
|
content="test message",
|
||||||
|
metadata=test_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify provider.chat was called
|
||||||
|
assert provider.chat.called
|
||||||
|
call_args = provider.chat.call_args
|
||||||
|
messages = call_args.kwargs["messages"]
|
||||||
|
|
||||||
|
# The user message should contain the content
|
||||||
|
# (We can't easily check InboundMessage directly, but we verify
|
||||||
|
# the flow worked by checking the session was created)
|
||||||
|
session = loop.sessions.get_or_create("cli:direct")
|
||||||
|
assert len(session.messages) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suppress_mode_adds_hidden_prefix():
|
||||||
|
"""Test that suppress_output metadata adds [HIDDEN:signature] prefix."""
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="This is the agent response",
|
||||||
|
tool_calls=[]
|
||||||
|
))
|
||||||
|
provider.get_default_model = MagicMock(return_value="test-model")
|
||||||
|
provider.thinking_budget = 0
|
||||||
|
|
||||||
|
workspace = Path("/tmp/test-workspace")
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||||
|
|
||||||
|
# Call with suppress_output=True
|
||||||
|
response = await loop.process_direct(
|
||||||
|
content="test message",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Response content should have [HIDDEN:signature] prefix with 8-char hex signature
|
||||||
|
assert response.startswith("[HIDDEN:")
|
||||||
|
assert "]" in response
|
||||||
|
# Extract signature part between [HIDDEN: and ]
|
||||||
|
prefix_end = response.index("]")
|
||||||
|
signature = response[8:prefix_end] # Skip "[HIDDEN:" to get signature
|
||||||
|
assert len(signature) == 8 # 8-character hex signature
|
||||||
|
assert all(c in "0123456789abcdef" for c in signature) # Valid hex
|
||||||
|
assert "This is the agent response" in response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_mode_no_hidden_prefix():
|
||||||
|
"""Test that normal messages don't get [HIDDEN:signature] prefix."""
|
||||||
|
bus = MessageBus()
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="Normal response",
|
||||||
|
tool_calls=[]
|
||||||
|
))
|
||||||
|
provider.get_default_model = MagicMock(return_value="test-model")
|
||||||
|
provider.thinking_budget = 0
|
||||||
|
|
||||||
|
workspace = Path("/tmp/test-workspace")
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||||
|
|
||||||
|
# Call without suppress_output
|
||||||
|
response = await loop.process_direct(content="test message")
|
||||||
|
|
||||||
|
# Response should NOT have [HIDDEN:signature] prefix
|
||||||
|
assert not response.startswith("[HIDDEN:")
|
||||||
|
assert response == "Normal response"
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Tests for agent loop handling of ToolResult and CLIResult objects."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider():
|
||||||
|
"""Create mock LLM provider."""
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.chat = AsyncMock()
|
||||||
|
provider.thinking_budget = 0
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session_manager():
|
||||||
|
"""Create mock session manager."""
|
||||||
|
session_mgr = MagicMock()
|
||||||
|
session_mgr.load = AsyncMock(return_value={
|
||||||
|
"messages": [],
|
||||||
|
"metadata": {},
|
||||||
|
})
|
||||||
|
session_mgr.save = AsyncMock()
|
||||||
|
return session_mgr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bus():
|
||||||
|
"""Create mock message bus."""
|
||||||
|
bus = MagicMock(spec=MessageBus)
|
||||||
|
bus.publish = AsyncMock()
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent_loop(mock_provider, mock_session_manager, mock_bus, tmp_path):
|
||||||
|
"""Create agent loop for testing."""
|
||||||
|
return AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
session_manager=mock_session_manager,
|
||||||
|
bus=mock_bus,
|
||||||
|
workspace=tmp_path,
|
||||||
|
max_iterations=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_result_with_output(agent_loop, mock_provider):
|
||||||
|
"""Test handling ToolResult with output field."""
|
||||||
|
# Mock LLM responses
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
# First call: request tool
|
||||||
|
LLMResponse(
|
||||||
|
content="Using tool",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||||
|
),
|
||||||
|
# Second call: final response
|
||||||
|
LLMResponse(content="Done"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock tool that returns ToolResult
|
||||||
|
tool_result = ToolResult(output="Tool executed successfully")
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
# Verify tool result was added to messages
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
|
||||||
|
# Find the tool result message
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
assert tool_msg["content"] == "Tool executed successfully"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_result_with_error(agent_loop, mock_provider):
|
||||||
|
"""Test handling ToolResult with error field."""
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
LLMResponse(
|
||||||
|
content="Using tool",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||||
|
),
|
||||||
|
LLMResponse(content="Error handled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
tool_result = ToolResult(error="Command failed: exit code 1")
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
assert "Error:" in tool_msg["content"]
|
||||||
|
assert "Command failed: exit code 1" in tool_msg["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_result_with_base64_image(agent_loop, mock_provider):
|
||||||
|
"""Test handling ToolResult with base64_image field."""
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
LLMResponse(
|
||||||
|
content="Taking screenshot",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="screenshot", arguments={})],
|
||||||
|
),
|
||||||
|
LLMResponse(content="Screenshot analyzed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
tool_result = ToolResult(
|
||||||
|
output="Screenshot taken",
|
||||||
|
base64_image="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
)
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
|
||||||
|
# Should contain both text and image
|
||||||
|
assert isinstance(tool_msg["content"], list)
|
||||||
|
assert len(tool_msg["content"]) == 2
|
||||||
|
|
||||||
|
# Text content
|
||||||
|
text_part = next(p for p in tool_msg["content"] if p["type"] == "text")
|
||||||
|
assert text_part["text"] == "Screenshot taken"
|
||||||
|
|
||||||
|
# Image content
|
||||||
|
image_part = next(p for p in tool_msg["content"] if p["type"] == "image")
|
||||||
|
assert image_part["source"]["type"] == "base64"
|
||||||
|
assert image_part["source"]["media_type"] == "image/png"
|
||||||
|
assert "iVBORw0KGgoAAAANS" in image_part["source"]["data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cli_result_handling(agent_loop, mock_provider):
|
||||||
|
"""Test handling CLIResult from text editor tools."""
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
LLMResponse(
|
||||||
|
content="Editing file",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="edit", arguments={})],
|
||||||
|
),
|
||||||
|
LLMResponse(content="File edited"),
|
||||||
|
]
|
||||||
|
|
||||||
|
cli_result = CLIResult(
|
||||||
|
exit_code=0,
|
||||||
|
output="File updated successfully",
|
||||||
|
error="",
|
||||||
|
)
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value=cli_result)
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
assert tool_msg["content"] == "File updated successfully"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_string_result(agent_loop, mock_provider):
|
||||||
|
"""Test backward compatibility with string results from function tools."""
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
LLMResponse(
|
||||||
|
content="Using tool",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="legacy_tool", arguments={})],
|
||||||
|
),
|
||||||
|
LLMResponse(content="Done"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Legacy tool returns plain string
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value="Plain text result")
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
assert tool_msg["content"] == "Plain text result"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_result_output_and_error(agent_loop, mock_provider):
|
||||||
|
"""Test handling ToolResult with both output and error."""
|
||||||
|
mock_provider.chat.side_effect = [
|
||||||
|
LLMResponse(
|
||||||
|
content="Running command",
|
||||||
|
tool_calls=[ToolCallRequest(id="call_1", name="bash", arguments={})],
|
||||||
|
),
|
||||||
|
LLMResponse(content="Handled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
tool_result = ToolResult(
|
||||||
|
output="Partial output before error",
|
||||||
|
error="Unexpected termination",
|
||||||
|
)
|
||||||
|
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||||
|
|
||||||
|
message = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
chat_id="123",
|
||||||
|
sender_id="user1",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await agent_loop._process_message(message)
|
||||||
|
|
||||||
|
calls = mock_provider.chat.call_args_list
|
||||||
|
second_call_messages = calls[1][1]["messages"]
|
||||||
|
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||||
|
|
||||||
|
# Should contain both output and error
|
||||||
|
content = tool_msg["content"]
|
||||||
|
assert "Partial output before error" in content
|
||||||
|
assert "Error:" in content
|
||||||
|
assert "Unexpected termination" in content
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for Anthropic native tool base classes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nanobot.agent.tools.anthropic.base import (
|
||||||
|
BaseAnthropicTool,
|
||||||
|
ToolResult,
|
||||||
|
CLIResult,
|
||||||
|
ToolError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyTool(BaseAnthropicTool):
|
||||||
|
"""Test tool implementation."""
|
||||||
|
api_type = "test_20250227"
|
||||||
|
name = "test_tool"
|
||||||
|
beta_flag = "test-beta"
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs):
|
||||||
|
return ToolResult(output="test output")
|
||||||
|
|
||||||
|
def to_params(self):
|
||||||
|
return {"type": self.api_type, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_result_dataclass():
|
||||||
|
"""Test ToolResult can be created with all fields."""
|
||||||
|
result = ToolResult(output="hello", error=None, base64_image=None, system="system message")
|
||||||
|
assert result.output == "hello"
|
||||||
|
assert result.error is None
|
||||||
|
assert result.base64_image is None
|
||||||
|
assert result.system == "system message"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_result_dataclass():
|
||||||
|
"""Test CLIResult can be created with all fields."""
|
||||||
|
result = CLIResult(exit_code=0, output="command output", error="")
|
||||||
|
assert result.output == "command output"
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.error == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_error_exception():
|
||||||
|
"""Test ToolError can be raised and caught."""
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
raise ToolError("Test error message")
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_anthropic_tool_to_params():
|
||||||
|
"""Test tool returns correct params format."""
|
||||||
|
tool = DummyTool()
|
||||||
|
params = tool.to_params()
|
||||||
|
assert params["type"] == "test_20250227"
|
||||||
|
assert params["name"] == "test_tool"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_anthropic_tool_call():
|
||||||
|
"""Test tool can be called and returns ToolResult."""
|
||||||
|
tool = DummyTool()
|
||||||
|
result = await tool()
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert result.output == "test output"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Tests for native tool support in AnthropicOAuthProvider."""
|
||||||
|
|
||||||
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_tools_passes_through_native_tools():
|
||||||
|
"""Test that native tool format is passed through unchanged."""
|
||||||
|
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "bash_20250124",
|
||||||
|
"name": "bash"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = provider._convert_tools_to_anthropic(tools)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["type"] == "bash_20250124"
|
||||||
|
assert result[0]["name"] == "bash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_tools_handles_mixed_tool_types():
|
||||||
|
"""Test conversion of both function and native tools."""
|
||||||
|
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "custom_tool",
|
||||||
|
"description": "A custom tool",
|
||||||
|
"parameters": {"type": "object", "properties": {"arg": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bash_20250124",
|
||||||
|
"name": "bash"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = provider._convert_tools_to_anthropic(tools)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
# Function tool gets converted
|
||||||
|
assert result[0]["name"] == "custom_tool"
|
||||||
|
assert result[0]["description"] == "A custom tool"
|
||||||
|
assert "input_schema" in result[0]
|
||||||
|
|
||||||
|
# Native tool passed through
|
||||||
|
assert result[1]["type"] == "bash_20250124"
|
||||||
|
assert result[1]["name"] == "bash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_tools_preserves_function_tool_conversion():
|
||||||
|
"""Test that existing function tool conversion still works."""
|
||||||
|
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "test",
|
||||||
|
"description": "desc",
|
||||||
|
"parameters": {"type": "object"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = provider._convert_tools_to_anthropic(tools)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "test"
|
||||||
|
assert result[0]["description"] == "desc"
|
||||||
|
assert result[0]["input_schema"] == {"type": "object"}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for BashTool20250124."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_tool_simple_command():
|
||||||
|
"""Test bash tool executes simple command."""
|
||||||
|
tool = BashTool20250124()
|
||||||
|
result = await tool(command="echo hello")
|
||||||
|
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert "hello" in result.output
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_tool_persistent_session():
|
||||||
|
"""Test bash tool maintains session across calls."""
|
||||||
|
tool = BashTool20250124()
|
||||||
|
|
||||||
|
# Set variable
|
||||||
|
result1 = await tool(command="export TEST_VAR=42")
|
||||||
|
assert result1.error is None
|
||||||
|
|
||||||
|
# Read variable (should persist)
|
||||||
|
result2 = await tool(command="echo $TEST_VAR")
|
||||||
|
assert "42" in result2.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bash_tool_restart():
|
||||||
|
"""Test bash tool can restart session."""
|
||||||
|
tool = BashTool20250124()
|
||||||
|
|
||||||
|
# Set variable
|
||||||
|
await tool(command="export TEST_VAR=42")
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
result = await tool(restart=True)
|
||||||
|
assert "restarted" in result.output.lower()
|
||||||
|
|
||||||
|
# Variable should be gone
|
||||||
|
result2 = await tool(command="echo $TEST_VAR")
|
||||||
|
assert "42" not in result2.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_tool_to_params():
|
||||||
|
"""Test bash tool returns correct params."""
|
||||||
|
tool = BashTool20250124()
|
||||||
|
params = tool.to_params()
|
||||||
|
|
||||||
|
assert params["type"] == "bash_20250124"
|
||||||
|
assert params["name"] == "bash"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for beta flag collection from native tools."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_beta_flags_collected_from_tools():
|
||||||
|
"""Test that beta flags are extracted from tool objects."""
|
||||||
|
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||||
|
|
||||||
|
# Mock tool objects with beta_flag attribute and to_params method
|
||||||
|
class MockTool:
|
||||||
|
def __init__(self, beta_flag):
|
||||||
|
self.beta_flag = beta_flag
|
||||||
|
|
||||||
|
def to_params(self):
|
||||||
|
return {"type": "bash_20250124", "name": "bash"}
|
||||||
|
|
||||||
|
tools_with_flags = [
|
||||||
|
MockTool("computer-use-2025-11-24"),
|
||||||
|
MockTool("computer-use-2025-11-24"), # Duplicate should be deduplicated
|
||||||
|
]
|
||||||
|
|
||||||
|
# We need to test this via the actual API call flow
|
||||||
|
# Mock httpx client
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": "msg_test",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": "test"}],
|
||||||
|
"model": "claude-opus-4",
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(provider, '_client') as mock_client:
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
# Call with messages and tools
|
||||||
|
await provider.chat(
|
||||||
|
messages=[{"role": "user", "content": "test"}],
|
||||||
|
model="claude-opus-4",
|
||||||
|
max_tokens=100,
|
||||||
|
tools=tools_with_flags
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that beta flag was added to headers
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
headers = call_args[1]["headers"]
|
||||||
|
assert "anthropic-beta" in headers
|
||||||
|
assert headers["anthropic-beta"] == "computer-use-2025-11-24"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_beta_flags_joined():
|
||||||
|
"""Test that multiple unique beta flags are joined with commas."""
|
||||||
|
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||||
|
|
||||||
|
class MockTool:
|
||||||
|
def __init__(self, beta_flag):
|
||||||
|
self.beta_flag = beta_flag
|
||||||
|
|
||||||
|
def to_params(self):
|
||||||
|
return {"type": "bash_20250124", "name": "bash"}
|
||||||
|
|
||||||
|
tools_with_flags = [
|
||||||
|
MockTool("flag-a"),
|
||||||
|
MockTool("flag-b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": "msg_test",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": "test"}],
|
||||||
|
"model": "claude-opus-4",
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(provider, '_client') as mock_client:
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
await provider.chat(
|
||||||
|
messages=[{"role": "user", "content": "test"}],
|
||||||
|
model="claude-opus-4",
|
||||||
|
max_tokens=100,
|
||||||
|
tools=tools_with_flags
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
headers = call_args[1]["headers"]
|
||||||
|
assert "anthropic-beta" in headers
|
||||||
|
# Should be sorted alphabetically and joined with comma
|
||||||
|
assert headers["anthropic-beta"] == "flag-a,flag-b"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for bus-level correlation (request-response via Futures)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bus():
|
||||||
|
return MessageBus()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_correlation_returns_future(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
assert isinstance(future, asyncio.Future)
|
||||||
|
assert not future.done()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_correlation_sets_future_result(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||||
|
bus.resolve_correlation(msg)
|
||||||
|
assert future.done()
|
||||||
|
assert future.result() == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_correlation_no_match_is_noop(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "other-id"})
|
||||||
|
bus.resolve_correlation(msg)
|
||||||
|
assert not future.done()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_correlation_no_metadata_is_noop(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
|
||||||
|
bus.resolve_correlation(msg)
|
||||||
|
assert not future.done()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_correlation_cleans_up_store(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||||
|
bus.resolve_correlation(msg)
|
||||||
|
assert "test-id-1" not in bus._correlation_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_correlation(bus):
|
||||||
|
future = bus.register_correlation("test-id-1")
|
||||||
|
bus.cancel_correlation("test-id-1")
|
||||||
|
assert "test-id-1" not in bus._correlation_store
|
||||||
|
assert future.cancelled()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests for ComputerTool20251124."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_computer_tool_screenshot():
|
||||||
|
"""Test computer tool can take screenshot."""
|
||||||
|
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||||
|
|
||||||
|
# Mock VNC client
|
||||||
|
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.captureScreen = AsyncMock(return_value=b"fake_png_data")
|
||||||
|
|
||||||
|
# Set up async context manager
|
||||||
|
mock_context = MagicMock()
|
||||||
|
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||||
|
|
||||||
|
result = await tool(action="screenshot")
|
||||||
|
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert result.base64_image is not None
|
||||||
|
assert len(result.base64_image) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_computer_tool_mouse_move():
|
||||||
|
"""Test computer tool can move mouse."""
|
||||||
|
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||||
|
|
||||||
|
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.mouseMove = AsyncMock()
|
||||||
|
|
||||||
|
# Set up async context manager
|
||||||
|
mock_context = MagicMock()
|
||||||
|
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||||
|
|
||||||
|
result = await tool(action="mouse_move", coordinate=[100, 200])
|
||||||
|
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert result.error is None
|
||||||
|
mock_client.mouseMove.assert_called_once_with(100, 200)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_computer_tool_key():
|
||||||
|
"""Test computer tool can press keys."""
|
||||||
|
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||||
|
|
||||||
|
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.keyPress = AsyncMock()
|
||||||
|
|
||||||
|
# Set up async context manager
|
||||||
|
mock_context = MagicMock()
|
||||||
|
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||||
|
|
||||||
|
result = await tool(action="key", text="Return")
|
||||||
|
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert result.error is None
|
||||||
|
mock_client.keyPress.assert_called_once_with("Return")
|
||||||
|
|
||||||
|
|
||||||
|
def test_computer_tool_to_params():
|
||||||
|
"""Test computer tool returns correct params."""
|
||||||
|
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||||
|
params = tool.to_params()
|
||||||
|
|
||||||
|
assert params["type"] == "computer_20251124"
|
||||||
|
assert params["name"] == "computer"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Tests for EditTool20250728."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||||
|
from nanobot.agent.tools.anthropic.base import CLIResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def edit_tool():
|
||||||
|
"""Create an EditTool20250728 instance."""
|
||||||
|
return EditTool20250728()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_file(tmp_path):
|
||||||
|
"""Create a temporary file with some content."""
|
||||||
|
file_path = tmp_path / "test.txt"
|
||||||
|
file_path.write_text("line 1\nline 2\nline 3\n")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_command(edit_tool, temp_file):
|
||||||
|
"""Test viewing a file with line numbers."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="view",
|
||||||
|
path=str(temp_file)
|
||||||
|
)
|
||||||
|
assert result.output is not None
|
||||||
|
assert "1|line 1" in result.output
|
||||||
|
assert "2|line 2" in result.output
|
||||||
|
assert "3|line 3" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_command(edit_tool, tmp_path):
|
||||||
|
"""Test creating a new file."""
|
||||||
|
new_file = tmp_path / "new.txt"
|
||||||
|
result = await edit_tool(
|
||||||
|
command="create",
|
||||||
|
path=str(new_file),
|
||||||
|
file_text="Hello\nWorld\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert new_file.exists()
|
||||||
|
assert new_file.read_text() == "Hello\nWorld\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_str_replace_command(edit_tool, temp_file):
|
||||||
|
"""Test replacing a unique string."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="str_replace",
|
||||||
|
path=str(temp_file),
|
||||||
|
old_str="line 2",
|
||||||
|
new_str="LINE TWO"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = temp_file.read_text()
|
||||||
|
assert "LINE TWO" in content
|
||||||
|
assert "line 2" not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_str_replace_non_unique(edit_tool, temp_file):
|
||||||
|
"""Test that str_replace fails on non-unique match."""
|
||||||
|
# Write content with duplicate "line"
|
||||||
|
temp_file.write_text("line 1\nline 2\nline 3\n")
|
||||||
|
result = await edit_tool(
|
||||||
|
command="str_replace",
|
||||||
|
path=str(temp_file),
|
||||||
|
old_str="line", # This appears 3 times
|
||||||
|
new_str="LINE"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "must match exactly once" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insert_command(edit_tool, temp_file):
|
||||||
|
"""Test inserting text at a specific line."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="insert",
|
||||||
|
path=str(temp_file),
|
||||||
|
insert_line=1,
|
||||||
|
new_str="inserted line\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = temp_file.read_text()
|
||||||
|
lines = content.splitlines()
|
||||||
|
assert lines[1] == "inserted line"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_tool_requires_absolute_path():
|
||||||
|
"""Test edit tool rejects relative paths."""
|
||||||
|
tool = EditTool20250728()
|
||||||
|
|
||||||
|
result = await tool(
|
||||||
|
command="view",
|
||||||
|
path="relative/path.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, CLIResult)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "absolute" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_tool_to_params():
|
||||||
|
"""Test edit tool returns correct params."""
|
||||||
|
tool = EditTool20250728()
|
||||||
|
params = tool.to_params()
|
||||||
|
|
||||||
|
assert params["type"] == "text_editor_20250728"
|
||||||
|
assert params["name"] == "str_replace_editor"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# tests/test_heartbeat_idle.py
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_heartbeat_skips_when_user_active():
|
||||||
|
"""Test that heartbeat doesn't trigger if user messaged recently."""
|
||||||
|
workspace = Path("/tmp/test-heartbeat")
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create session with recent user message
|
||||||
|
sessions = SessionManager(workspace)
|
||||||
|
session = sessions.get_or_create("telegram:239824268")
|
||||||
|
session.add_message("user", "Recent message")
|
||||||
|
sessions.save(session)
|
||||||
|
|
||||||
|
# Create heartbeat callback
|
||||||
|
callback_called = False
|
||||||
|
async def on_heartbeat(prompt, metadata=None):
|
||||||
|
nonlocal callback_called
|
||||||
|
callback_called = True
|
||||||
|
return "response"
|
||||||
|
|
||||||
|
# Create heartbeat service
|
||||||
|
service = HeartbeatService(
|
||||||
|
workspace=workspace,
|
||||||
|
on_heartbeat=on_heartbeat,
|
||||||
|
interval_s=1, # Short interval for testing
|
||||||
|
enabled=True,
|
||||||
|
session_manager=sessions,
|
||||||
|
target_session_key="telegram:239824268"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger heartbeat
|
||||||
|
await service._tick()
|
||||||
|
|
||||||
|
# Callback should NOT have been called (user was active recently)
|
||||||
|
assert not callback_called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_heartbeat_triggers_when_user_idle():
|
||||||
|
"""Test that heartbeat triggers after 30min of inactivity."""
|
||||||
|
workspace = Path("/tmp/test-heartbeat")
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create HEARTBEAT.md with content
|
||||||
|
heartbeat_file = workspace / "HEARTBEAT.md"
|
||||||
|
heartbeat_file.write_text("# Tasks\n- Check something\n")
|
||||||
|
|
||||||
|
# Create session with old user message (>30min ago)
|
||||||
|
sessions = SessionManager(workspace)
|
||||||
|
session = sessions.get_or_create("telegram:239824268")
|
||||||
|
|
||||||
|
# Manually set old timestamp
|
||||||
|
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
|
||||||
|
session.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": "Old message",
|
||||||
|
"timestamp": old_timestamp
|
||||||
|
})
|
||||||
|
sessions.save(session)
|
||||||
|
|
||||||
|
# Create heartbeat callback
|
||||||
|
callback_called = False
|
||||||
|
callback_metadata = None
|
||||||
|
|
||||||
|
async def on_heartbeat(prompt, metadata=None):
|
||||||
|
nonlocal callback_called, callback_metadata
|
||||||
|
callback_called = True
|
||||||
|
callback_metadata = metadata
|
||||||
|
return "response"
|
||||||
|
|
||||||
|
# Create heartbeat service
|
||||||
|
service = HeartbeatService(
|
||||||
|
workspace=workspace,
|
||||||
|
on_heartbeat=on_heartbeat,
|
||||||
|
interval_s=1,
|
||||||
|
enabled=True,
|
||||||
|
session_manager=sessions,
|
||||||
|
target_session_key="telegram:239824268"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger heartbeat
|
||||||
|
await service._tick()
|
||||||
|
|
||||||
|
# Callback SHOULD have been called (user idle for >30min)
|
||||||
|
assert callback_called
|
||||||
|
assert callback_metadata == {"suppress_output": True}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# tests/test_heartbeat_idle_detection.py
|
||||||
|
"""Tests for heartbeat idle detection with sender_id filtering."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_detection_ignores_system_messages():
|
||||||
|
"""Test that heartbeat only counts real user messages for idle detection."""
|
||||||
|
# Create session manager and session
|
||||||
|
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||||
|
session = session_manager.get_or_create("telegram:239824268")
|
||||||
|
|
||||||
|
# Add a real user message 45 minutes ago
|
||||||
|
real_user_time = datetime.now() - timedelta(minutes=45)
|
||||||
|
session.add_message(
|
||||||
|
"user",
|
||||||
|
"This is a real user message",
|
||||||
|
sender_id="239824268|testuser",
|
||||||
|
timestamp=real_user_time.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a heartbeat system message 10 minutes ago (should be ignored)
|
||||||
|
heartbeat_time = datetime.now() - timedelta(minutes=10)
|
||||||
|
session.add_message(
|
||||||
|
"user",
|
||||||
|
"Read HEARTBEAT.md...",
|
||||||
|
sender_id="user",
|
||||||
|
timestamp=heartbeat_time.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add an assistant response
|
||||||
|
session.add_message("assistant", "Response to heartbeat")
|
||||||
|
|
||||||
|
# Create heartbeat service with 30 minute idle threshold
|
||||||
|
heartbeat = HeartbeatService(
|
||||||
|
workspace=Path("/tmp/test-heartbeat"),
|
||||||
|
session_manager=session_manager,
|
||||||
|
target_session_key="telegram:239824268",
|
||||||
|
idle_threshold_s=30 * 60, # 30 minutes
|
||||||
|
interval_s=30 * 60,
|
||||||
|
enabled=False # Don't actually start the loop
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manually check idle logic (replicate _tick logic)
|
||||||
|
session = session_manager.get_or_create("telegram:239824268")
|
||||||
|
|
||||||
|
# Find last user message timestamp (should find the 45-minute-old message, not the 10-minute-old one)
|
||||||
|
last_user_timestamp = None
|
||||||
|
for msg in reversed(session.messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
sender_id = msg.get("sender_id")
|
||||||
|
if sender_id == "user":
|
||||||
|
continue
|
||||||
|
last_user_timestamp = msg.get("timestamp")
|
||||||
|
break
|
||||||
|
|
||||||
|
assert last_user_timestamp is not None
|
||||||
|
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||||
|
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||||
|
|
||||||
|
# Should detect user is idle (45 minutes > 30 minute threshold)
|
||||||
|
assert elapsed >= 30 * 60, f"Expected idle (45min), but elapsed={elapsed/60:.1f}min"
|
||||||
|
# Should NOT be 10 minutes (heartbeat message was ignored)
|
||||||
|
assert elapsed >= 40 * 60, f"Heartbeat message was not ignored, elapsed={elapsed/60:.1f}min"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_detection_counts_real_user_messages():
|
||||||
|
"""Test that heartbeat correctly identifies when user is active."""
|
||||||
|
# Create session manager and session
|
||||||
|
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||||
|
session = session_manager.get_or_create("telegram:239824268")
|
||||||
|
|
||||||
|
# Add a real user message 10 minutes ago (recent activity)
|
||||||
|
real_user_time = datetime.now() - timedelta(minutes=10)
|
||||||
|
session.add_message(
|
||||||
|
"user",
|
||||||
|
"This is a recent user message",
|
||||||
|
sender_id="239824268|testuser",
|
||||||
|
timestamp=real_user_time.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create heartbeat service with 30 minute idle threshold
|
||||||
|
heartbeat = HeartbeatService(
|
||||||
|
workspace=Path("/tmp/test-heartbeat"),
|
||||||
|
session_manager=session_manager,
|
||||||
|
target_session_key="telegram:239824268",
|
||||||
|
idle_threshold_s=30 * 60, # 30 minutes
|
||||||
|
interval_s=30 * 60,
|
||||||
|
enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find last user message timestamp
|
||||||
|
session = session_manager.get_or_create("telegram:239824268")
|
||||||
|
last_user_timestamp = None
|
||||||
|
for msg in reversed(session.messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
sender_id = msg.get("sender_id")
|
||||||
|
if sender_id == "user":
|
||||||
|
continue
|
||||||
|
last_user_timestamp = msg.get("timestamp")
|
||||||
|
break
|
||||||
|
|
||||||
|
assert last_user_timestamp is not None
|
||||||
|
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||||
|
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||||
|
|
||||||
|
# Should detect user is active (10 minutes < 30 minute threshold)
|
||||||
|
assert elapsed < 30 * 60, f"Expected active (10min), but elapsed={elapsed/60:.1f}min"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backwards_compat_messages_without_sender_id():
|
||||||
|
"""Test that old messages without sender_id are treated as real user messages."""
|
||||||
|
# Create session manager and session
|
||||||
|
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||||
|
session = session_manager.get_or_create("telegram:239824268")
|
||||||
|
|
||||||
|
# Add an old message without sender_id (backwards compat)
|
||||||
|
old_time = datetime.now() - timedelta(minutes=20)
|
||||||
|
session.add_message(
|
||||||
|
"user",
|
||||||
|
"Old message without sender_id",
|
||||||
|
timestamp=old_time.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find last user message timestamp (should find the old message)
|
||||||
|
last_user_timestamp = None
|
||||||
|
for msg in reversed(session.messages):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
sender_id = msg.get("sender_id")
|
||||||
|
if sender_id == "user":
|
||||||
|
continue
|
||||||
|
last_user_timestamp = msg.get("timestamp")
|
||||||
|
break
|
||||||
|
|
||||||
|
assert last_user_timestamp is not None
|
||||||
|
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||||
|
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||||
|
|
||||||
|
# Should accept old message (backwards compat)
|
||||||
|
assert elapsed < 25 * 60, f"Old message not counted, elapsed={elapsed/60:.1f}min"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Tests for the hook channel."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from nanobot.channels.hook import HookChannel
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bus():
|
||||||
|
return MessageBus()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hook_channel_name():
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = HookChannel(bus)
|
||||||
|
assert channel.name == "hook"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_channel_send_is_noop():
|
||||||
|
"""send() should not raise and should not do anything."""
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = HookChannel(bus)
|
||||||
|
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
|
||||||
|
await channel.send(msg) # Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_channel_start_stop():
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = HookChannel(bus)
|
||||||
|
await channel.start()
|
||||||
|
assert channel.is_running
|
||||||
|
await channel.stop()
|
||||||
|
assert not channel.is_running
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for HooksConfig with named tokens."""
|
||||||
|
|
||||||
|
from nanobot.config.schema import HooksConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_hooks_config_named_tokens():
|
||||||
|
"""Named tokens dict should work."""
|
||||||
|
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||||
|
assert config.tokens == {"gitea": "secret1", "ha": "secret2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_hooks_config_resolve_token():
|
||||||
|
"""resolve_token should return token name for a matching token."""
|
||||||
|
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||||
|
assert config.resolve_token("secret1") == "gitea"
|
||||||
|
assert config.resolve_token("secret2") == "ha"
|
||||||
|
assert config.resolve_token("unknown") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_hooks_config_has_tokens():
|
||||||
|
"""has_tokens should be True if tokens dict is non-empty."""
|
||||||
|
assert HooksConfig(enabled=True, tokens={"webhook": "secret"}).has_tokens
|
||||||
|
assert not HooksConfig(enabled=True).has_tokens
|
||||||
|
assert not HooksConfig(enabled=True, tokens={}).has_tokens
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""End-to-end integration test for hooks → bus → correlation → response."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
from nanobot.hooks.server import HooksServer
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.config.schema import HooksConfig
|
||||||
|
from nanobot.channels.hook import HookChannel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bus():
|
||||||
|
return MessageBus()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config():
|
||||||
|
return HooksConfig(
|
||||||
|
enabled=True,
|
||||||
|
tokens={"gitea": "gitea-secret", "ha": "ha-secret"},
|
||||||
|
timeout_seconds=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(bus, config):
|
||||||
|
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_agent_loop(bus: MessageBus):
|
||||||
|
"""Simulate agent loop: consume inbound, process, publish outbound."""
|
||||||
|
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=3.0)
|
||||||
|
response_content = f"Processed: {msg.content}"
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel=msg.channel,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
content=response_content,
|
||||||
|
metadata=msg.metadata or {},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_dispatch_loop(bus: MessageBus, hook_channel: HookChannel):
|
||||||
|
"""Simulate outbound dispatcher: consume outbound, resolve correlation, dispatch."""
|
||||||
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=3.0)
|
||||||
|
bus.resolve_correlation(msg)
|
||||||
|
if msg.channel == "hook":
|
||||||
|
await hook_channel.send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_hook_flow_default_channel(server, bus):
|
||||||
|
"""Hook with default channel: message goes through bus, response returned to HTTP caller."""
|
||||||
|
hook_channel = HookChannel(bus)
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
async def do_request():
|
||||||
|
return await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "deploy started"},
|
||||||
|
headers={"Authorization": "Bearer gitea-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run request + fake agent + fake dispatcher concurrently
|
||||||
|
request_task = asyncio.create_task(do_request())
|
||||||
|
agent_task = asyncio.create_task(fake_agent_loop(bus))
|
||||||
|
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
|
||||||
|
|
||||||
|
resp = await asyncio.wait_for(request_task, timeout=5.0)
|
||||||
|
await agent_task
|
||||||
|
await dispatch_task
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "deploy started" in data["response"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_hook_flow_telegram_channel(server, bus):
|
||||||
|
"""Hook targeting telegram: uses telegram session, response still returned to HTTP caller."""
|
||||||
|
hook_channel = HookChannel(bus)
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
async def do_request():
|
||||||
|
return await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "doorbell rang", "channel": "telegram", "chat_id": "239824268"},
|
||||||
|
headers={"Authorization": "Bearer ha-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
request_task = asyncio.create_task(do_request())
|
||||||
|
agent_task = asyncio.create_task(fake_agent_loop(bus))
|
||||||
|
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
|
||||||
|
|
||||||
|
resp = await asyncio.wait_for(request_task, timeout=5.0)
|
||||||
|
await agent_task
|
||||||
|
await dispatch_task
|
||||||
|
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "doorbell rang" in data["response"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_named_token_identification(server, bus):
|
||||||
|
"""Different tokens should produce different hook_source in metadata."""
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
asyncio.create_task(client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "from gitea"},
|
||||||
|
headers={"Authorization": "Bearer gitea-secret"},
|
||||||
|
))
|
||||||
|
msg1 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||||
|
assert msg1.metadata["hook_source"] == "gitea"
|
||||||
|
assert msg1.chat_id == "gitea"
|
||||||
|
|
||||||
|
asyncio.create_task(client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "from ha"},
|
||||||
|
headers={"Authorization": "Bearer ha-secret"},
|
||||||
|
))
|
||||||
|
msg2 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||||
|
assert msg2.metadata["hook_source"] == "ha"
|
||||||
|
assert msg2.chat_id == "ha"
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Tests for the rewritten hooks server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop, TestClient, TestServer
|
||||||
|
from nanobot.hooks.server import HooksServer
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.config.schema import HooksConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bus():
|
||||||
|
return MessageBus()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config():
|
||||||
|
return HooksConfig(
|
||||||
|
enabled=True,
|
||||||
|
tokens={"test-hook": "test-secret-123"},
|
||||||
|
timeout_seconds=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(bus, config):
|
||||||
|
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check(server):
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthorized_without_token(server):
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.post("/hooks", json={"message": "test"})
|
||||||
|
assert resp.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthorized_wrong_token(server):
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "test"},
|
||||||
|
headers={"Authorization": "Bearer wrong-token"},
|
||||||
|
)
|
||||||
|
assert resp.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_message_field(server, bus):
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"not_message": "test"},
|
||||||
|
headers={"Authorization": "Bearer test-secret-123"},
|
||||||
|
)
|
||||||
|
assert resp.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_publishes_to_bus(server, bus):
|
||||||
|
"""Hook should publish InboundMessage to bus and the message should contain hook prefix."""
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
# Send hook request in background (it will block waiting for correlation)
|
||||||
|
async def send_request():
|
||||||
|
return await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "hello from webhook"},
|
||||||
|
headers={"Authorization": "Bearer test-secret-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = asyncio.create_task(send_request())
|
||||||
|
|
||||||
|
# Consume the inbound message
|
||||||
|
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||||
|
|
||||||
|
assert msg.channel == "hook"
|
||||||
|
assert msg.chat_id == "test-hook" # defaults to token name
|
||||||
|
assert msg.metadata.get("hook_source") == "test-hook"
|
||||||
|
assert msg.metadata.get("correlation_id") is not None
|
||||||
|
|
||||||
|
# Simulate agent response by resolving correlation
|
||||||
|
bus.resolve_correlation(OutboundMessage(
|
||||||
|
channel="hook",
|
||||||
|
chat_id="test-hook",
|
||||||
|
content="agent says hi",
|
||||||
|
metadata={"correlation_id": msg.metadata["correlation_id"]},
|
||||||
|
))
|
||||||
|
|
||||||
|
resp = await asyncio.wait_for(task, timeout=2.0)
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["response"] == "agent says hi"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_with_custom_channel(server, bus):
|
||||||
|
"""Hook targeting telegram should use telegram channel in InboundMessage."""
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
async def send_request():
|
||||||
|
return await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "notify user", "channel": "telegram", "chat_id": "239824268"},
|
||||||
|
headers={"Authorization": "Bearer test-secret-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = asyncio.create_task(send_request())
|
||||||
|
|
||||||
|
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||||
|
assert msg.channel == "telegram"
|
||||||
|
assert msg.chat_id == "239824268"
|
||||||
|
assert msg.session_key == "telegram:239824268"
|
||||||
|
|
||||||
|
bus.resolve_correlation(OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="239824268",
|
||||||
|
content="done",
|
||||||
|
metadata={"correlation_id": msg.metadata["correlation_id"]},
|
||||||
|
))
|
||||||
|
|
||||||
|
resp = await asyncio.wait_for(task, timeout=2.0)
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["response"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_timeout_returns_504(bus):
|
||||||
|
"""If agent doesn't respond in time, return 504."""
|
||||||
|
config = HooksConfig(enabled=True, tokens={"test-hook": "test-secret-123"}, timeout_seconds=1)
|
||||||
|
server = HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "slow request"},
|
||||||
|
headers={"Authorization": "Bearer test-secret-123"},
|
||||||
|
)
|
||||||
|
assert resp.status == 504
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_timeout_zero_returns_202(server, bus):
|
||||||
|
"""timeout=0 should return 202 immediately without waiting."""
|
||||||
|
client = TestClient(TestServer(server._app))
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/hooks",
|
||||||
|
json={"message": "fire and forget", "timeout": 0},
|
||||||
|
headers={"Authorization": "Bearer test-secret-123"},
|
||||||
|
)
|
||||||
|
assert resp.status == 202
|
||||||
|
data = await resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
# Message should still be on the bus
|
||||||
|
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
|
||||||
|
assert msg.content == "fire and forget"
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# tests/test_idle_heartbeat_integration.py
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_heartbeat_end_to_end(tmp_path):
|
||||||
|
"""
|
||||||
|
Integration test: heartbeat triggers when idle, runs in main session,
|
||||||
|
output is suppressed, session contains [HIDDEN:signature] content.
|
||||||
|
"""
|
||||||
|
workspace = tmp_path / "test-integration"
|
||||||
|
workspace.mkdir()
|
||||||
|
|
||||||
|
# Use test-specific session key
|
||||||
|
test_session_key = "telegram:test_integration"
|
||||||
|
|
||||||
|
# Create HEARTBEAT.md with content
|
||||||
|
heartbeat_file = workspace / "HEARTBEAT.md"
|
||||||
|
heartbeat_file.write_text("# Test Task\n- Check something")
|
||||||
|
|
||||||
|
# Create mock provider
|
||||||
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||||
|
content="Heartbeat executed successfully",
|
||||||
|
tool_calls=[] # has_tool_calls is a property, not a parameter
|
||||||
|
))
|
||||||
|
provider.get_default_model = MagicMock(return_value="test-model")
|
||||||
|
provider.thinking_budget = 0
|
||||||
|
|
||||||
|
# Create components
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(workspace)
|
||||||
|
# Override sessions_dir to use tmp_path for test isolation
|
||||||
|
sessions.sessions_dir = tmp_path / "sessions"
|
||||||
|
sessions.sessions_dir.mkdir()
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=workspace,
|
||||||
|
session_manager=sessions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session with old user message
|
||||||
|
session = sessions.get_or_create(test_session_key)
|
||||||
|
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
|
||||||
|
session.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": "Old user message",
|
||||||
|
"timestamp": old_timestamp
|
||||||
|
})
|
||||||
|
sessions.save(session)
|
||||||
|
|
||||||
|
# Create heartbeat callback
|
||||||
|
async def on_heartbeat(prompt: str, metadata=None):
|
||||||
|
return await loop.process_direct(
|
||||||
|
prompt,
|
||||||
|
session_key=test_session_key,
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="test_integration",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create heartbeat service
|
||||||
|
heartbeat = HeartbeatService(
|
||||||
|
workspace=workspace,
|
||||||
|
on_heartbeat=on_heartbeat,
|
||||||
|
interval_s=1,
|
||||||
|
enabled=True,
|
||||||
|
session_manager=sessions,
|
||||||
|
target_session_key=test_session_key,
|
||||||
|
idle_threshold_s=30 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger heartbeat
|
||||||
|
await heartbeat._tick()
|
||||||
|
|
||||||
|
# Reload session from disk
|
||||||
|
sessions._cache.clear() # Clear cache to force reload
|
||||||
|
session = sessions.get_or_create(test_session_key)
|
||||||
|
|
||||||
|
# Verify:
|
||||||
|
# 1. Session has new messages
|
||||||
|
assert len(session.messages) > 1
|
||||||
|
|
||||||
|
# 2. Find the heartbeat response (assistant message with signed visibility marker)
|
||||||
|
heartbeat_messages = [
|
||||||
|
m for m in session.messages
|
||||||
|
if m.get("role") == "assistant" and "[HIDDEN:" in m.get("content", "")
|
||||||
|
]
|
||||||
|
assert len(heartbeat_messages) == 1, "Expected exactly 1 [HIDDEN:signature] heartbeat message"
|
||||||
|
|
||||||
|
# 3. Verify content is prefixed with [HIDDEN:signature]
|
||||||
|
heartbeat_msg = heartbeat_messages[0]
|
||||||
|
assert heartbeat_msg["content"].startswith("[HIDDEN:")
|
||||||
|
# Verify signature format (8-char hex)
|
||||||
|
content = heartbeat_msg["content"]
|
||||||
|
prefix_end = content.index("]")
|
||||||
|
signature = content[8:prefix_end] # Skip "[HIDDEN:" to get signature
|
||||||
|
assert len(signature) == 8, f"Expected 8-char signature, got {len(signature)}"
|
||||||
|
assert all(c in "0123456789abcdef" for c in signature), "Signature should be hex"
|
||||||
|
assert "Heartbeat executed successfully" in heartbeat_msg["content"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for screenshot media tracking."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_tracking_saves_screenshots():
|
||||||
|
"""Test that screenshots are saved to disk and tracked."""
|
||||||
|
# This is more of an integration test
|
||||||
|
# Test the media saving logic separately
|
||||||
|
|
||||||
|
# Create fake screenshot data
|
||||||
|
fake_png = b"\x89PNG\r\n\x1a\n" # PNG header
|
||||||
|
base64_image = base64.b64encode(fake_png).decode()
|
||||||
|
|
||||||
|
result = ToolResult(base64_image=base64_image)
|
||||||
|
|
||||||
|
# Verify we can decode it
|
||||||
|
decoded = base64.b64decode(result.base64_image)
|
||||||
|
assert decoded == fake_png
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Test registration of native Anthropic tools in the agent loop."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.anthropic import (
|
||||||
|
BashTool20250124,
|
||||||
|
EditTool20250728,
|
||||||
|
ComputerTool20251124,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider():
|
||||||
|
"""Create a mock provider."""
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.chat = AsyncMock(return_value="test response")
|
||||||
|
provider.get_default_model = MagicMock(return_value="test-model")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bus():
|
||||||
|
"""Create a mock message bus."""
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_outbound = AsyncMock()
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_tools_registered(mock_provider, mock_bus, tmp_path):
|
||||||
|
"""Test that native Anthropic tools are registered in the agent loop."""
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
bus=mock_bus,
|
||||||
|
workspace=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all registered tool names
|
||||||
|
tool_names = [tool.name for tool in loop.tools._tools.values()]
|
||||||
|
|
||||||
|
# Verify native tools are registered (using their internal names)
|
||||||
|
assert "bash" in tool_names, "bash tool should be registered"
|
||||||
|
assert "str_replace_editor" in tool_names, "str_replace_editor tool should be registered"
|
||||||
|
assert "computer" in tool_names, "computer tool should be registered"
|
||||||
|
|
||||||
|
# Verify we can get the tool instances
|
||||||
|
bash_tool = loop.tools.get("bash")
|
||||||
|
assert isinstance(bash_tool, BashTool20250124)
|
||||||
|
|
||||||
|
editor_tool = loop.tools.get("str_replace_editor")
|
||||||
|
assert isinstance(editor_tool, EditTool20250728)
|
||||||
|
|
||||||
|
computer_tool = loop.tools.get("computer")
|
||||||
|
assert isinstance(computer_tool, ComputerTool20251124)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for correlation resolution in outbound dispatch."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_resolves_correlation_before_channel_send():
|
||||||
|
"""Correlation Future should be resolved when outbound message is dispatched."""
|
||||||
|
bus = MessageBus()
|
||||||
|
future = bus.register_correlation("corr-1")
|
||||||
|
|
||||||
|
msg = OutboundMessage(channel="telegram", chat_id="123", content="response", metadata={"correlation_id": "corr-1"})
|
||||||
|
await bus.publish_outbound(msg)
|
||||||
|
|
||||||
|
# Simulate what _dispatch_outbound does: consume + resolve
|
||||||
|
consumed = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||||
|
bus.resolve_correlation(consumed)
|
||||||
|
|
||||||
|
assert future.done()
|
||||||
|
assert future.result() == "response"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Tests for registry duck typing support."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
class MockNativeTool(BaseAnthropicTool):
|
||||||
|
"""Mock native tool for testing."""
|
||||||
|
api_type = "test_20250227"
|
||||||
|
name = "native_test"
|
||||||
|
beta_flag = "test-beta"
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs):
|
||||||
|
return ToolResult(output="native result")
|
||||||
|
|
||||||
|
def to_params(self):
|
||||||
|
return {"type": self.api_type, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class MockFunctionTool:
|
||||||
|
"""Mock function tool for testing."""
|
||||||
|
def __init__(self):
|
||||||
|
self.name = "function_test"
|
||||||
|
|
||||||
|
def to_schema(self):
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": "Test function tool",
|
||||||
|
"parameters": {"type": "object", "properties": {}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, **kwargs):
|
||||||
|
return "function result"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_supports_native_tools():
|
||||||
|
"""Test registry can register and get definitions from native tools."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
native_tool = MockNativeTool()
|
||||||
|
registry.register(native_tool)
|
||||||
|
|
||||||
|
definitions = registry.get_definitions()
|
||||||
|
assert len(definitions) == 1
|
||||||
|
assert definitions[0]["type"] == "test_20250227"
|
||||||
|
assert definitions[0]["name"] == "native_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_supports_function_tools():
|
||||||
|
"""Test registry still supports function tools."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
function_tool = MockFunctionTool()
|
||||||
|
registry.register(function_tool)
|
||||||
|
|
||||||
|
definitions = registry.get_definitions()
|
||||||
|
assert len(definitions) == 1
|
||||||
|
assert definitions[0]["type"] == "function"
|
||||||
|
assert definitions[0]["function"]["name"] == "function_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_supports_mixed_tools():
|
||||||
|
"""Test registry can handle both native and function tools."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
native_tool = MockNativeTool()
|
||||||
|
function_tool = MockFunctionTool()
|
||||||
|
|
||||||
|
registry.register(native_tool)
|
||||||
|
registry.register(function_tool)
|
||||||
|
|
||||||
|
definitions = registry.get_definitions()
|
||||||
|
assert len(definitions) == 2
|
||||||
|
|
||||||
|
# Find each tool type in definitions
|
||||||
|
native_def = next(d for d in definitions if d.get("type") == "test_20250227")
|
||||||
|
function_def = next(d for d in definitions if d.get("type") == "function")
|
||||||
|
|
||||||
|
assert native_def["name"] == "native_test"
|
||||||
|
assert function_def["function"]["name"] == "function_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_tools_without_schema_method():
|
||||||
|
"""Test registry raises error for tools with no schema method."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
class BadTool:
|
||||||
|
name = "bad"
|
||||||
|
|
||||||
|
registry.register(BadTool())
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="has no schema method"):
|
||||||
|
registry.get_definitions()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests for registry execution of native tools."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.agent.tools.anthropic import BashTool20250124, EditTool20250728
|
||||||
|
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registry_executes_bash_tool():
|
||||||
|
"""Test registry can execute BashTool20250124 and returns ToolResult."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.register(BashTool20250124())
|
||||||
|
|
||||||
|
result = await registry.execute("bash", {"command": "echo 'test'"})
|
||||||
|
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert result.output is not None
|
||||||
|
assert "test" in result.output
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registry_executes_edit_tool():
|
||||||
|
"""Test registry can execute EditTool20250728 and returns CLIResult."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.register(EditTool20250728())
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_file = str(Path(tmpdir) / "test.txt")
|
||||||
|
|
||||||
|
result = await registry.execute("str_replace_editor", {
|
||||||
|
"command": "create",
|
||||||
|
"path": test_file,
|
||||||
|
"file_text": "Hello, world!"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert isinstance(result, CLIResult)
|
||||||
|
assert "created" in result.output.lower() or "success" in result.output.lower()
|
||||||
|
assert Path(test_file).exists()
|
||||||
|
assert Path(test_file).read_text() == "Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registry_mixed_tools():
|
||||||
|
"""Test registry can execute both native and function tools in same registry."""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
# Register native tool
|
||||||
|
registry.register(BashTool20250124())
|
||||||
|
|
||||||
|
# Execute native tool
|
||||||
|
result = await registry.execute("bash", {"command": "echo 'native'"})
|
||||||
|
assert isinstance(result, ToolResult)
|
||||||
|
assert "native" in result.output
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Integration tests for Telegram media sending."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_telegram_app():
|
||||||
|
"""Mock python-telegram-bot Application."""
|
||||||
|
app = MagicMock()
|
||||||
|
app.bot = MagicMock()
|
||||||
|
app.bot.send_photo = AsyncMock()
|
||||||
|
app.bot.send_video = AsyncMock()
|
||||||
|
app.bot.send_audio = AsyncMock()
|
||||||
|
app.bot.send_document = AsyncMock()
|
||||||
|
app.bot.send_media_group = AsyncMock()
|
||||||
|
app.bot.send_message = AsyncMock()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_single_image(mock_telegram_app, tmp_path):
|
||||||
|
"""Test sending single image."""
|
||||||
|
# Create test image
|
||||||
|
from PIL import Image
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img.save(img_path, format="JPEG")
|
||||||
|
|
||||||
|
# Setup channel
|
||||||
|
bus = MessageBus()
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "fake_token"
|
||||||
|
config.proxy = None
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
channel._app = mock_telegram_app
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
# Send message with media
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="12345",
|
||||||
|
content="Test image",
|
||||||
|
media=[str(img_path)]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.channels.telegram._markdown_to_telegram_html", return_value="Test image"):
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Verify send_photo was called
|
||||||
|
mock_telegram_app.bot.send_photo.assert_called_once()
|
||||||
|
call_args = mock_telegram_app.bot.send_photo.call_args
|
||||||
|
assert call_args.kwargs["chat_id"] == 12345
|
||||||
|
assert call_args.kwargs["caption"] == "Test image"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_album(mock_telegram_app, tmp_path):
|
||||||
|
"""Test sending multiple images as album."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Create test images
|
||||||
|
img_paths = []
|
||||||
|
for i in range(3):
|
||||||
|
img_path = tmp_path / f"test{i}.jpg"
|
||||||
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img.save(img_path, format="JPEG")
|
||||||
|
img_paths.append(str(img_path))
|
||||||
|
|
||||||
|
# Setup channel
|
||||||
|
bus = MessageBus()
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "fake_token"
|
||||||
|
config.proxy = None
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
channel._app = mock_telegram_app
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
# Send message with multiple images
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="12345",
|
||||||
|
content="Album test",
|
||||||
|
media=img_paths
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.channels.telegram._markdown_to_telegram_html", return_value="Album test"):
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Verify send_media_group was called
|
||||||
|
mock_telegram_app.bot.send_media_group.assert_called_once()
|
||||||
|
call_args = mock_telegram_app.bot.send_media_group.call_args
|
||||||
|
assert call_args.kwargs["chat_id"] == 12345
|
||||||
|
assert len(call_args.kwargs["media"]) == 3
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Tests for Telegram media handling."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_from_jpeg():
|
||||||
|
"""Test MIME detection for JPEG images."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
# Create minimal JPEG bytes (FF D8 FF = JPEG magic bytes)
|
||||||
|
jpeg_bytes = b'\xff\xd8\xff\xe0\x00\x10JFIF'
|
||||||
|
|
||||||
|
mime = detect_mime("test.jpg", jpeg_bytes)
|
||||||
|
assert mime == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_from_png():
|
||||||
|
"""Test MIME detection for PNG images."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
# PNG magic bytes
|
||||||
|
png_bytes = b'\x89PNG\r\n\x1a\n'
|
||||||
|
|
||||||
|
mime = detect_mime("test.png", png_bytes)
|
||||||
|
assert mime == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_from_extension_fallback():
|
||||||
|
"""Test MIME detection falls back to extension when no content provided."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
mime = detect_mime("video.mp4", None)
|
||||||
|
assert mime == "video/mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_unknown():
|
||||||
|
"""Test MIME detection returns generic type for unknown files."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
mime = detect_mime("unknown.xyz", None)
|
||||||
|
assert mime == "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_magic_fallback_on_octet_stream():
|
||||||
|
"""Test that extension is preferred when magic returns generic type."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
# Generic binary content that magic might identify as octet-stream
|
||||||
|
generic_bytes = b'\x00\x01\x02\x03'
|
||||||
|
|
||||||
|
# But extension clearly indicates it's an image
|
||||||
|
mime = detect_mime("image.png", generic_bytes)
|
||||||
|
|
||||||
|
# Should use extension (png) not magic's generic result
|
||||||
|
# Note: This tests the logic at line 36 - avoiding generic types
|
||||||
|
assert mime in ("image/png", "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_mime_malformed_content():
|
||||||
|
"""Test fallback when magic detection fails with malformed content."""
|
||||||
|
from nanobot.channels.telegram_media import detect_mime
|
||||||
|
|
||||||
|
# Malformed content that might cause magic to raise an exception
|
||||||
|
malformed = b'\xff' * 10
|
||||||
|
|
||||||
|
# Should fallback to extension detection, not crash
|
||||||
|
mime = detect_mime("test.mp4", malformed)
|
||||||
|
assert mime == "video/mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_media_image():
|
||||||
|
"""Test classification of image MIME types."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, classify_media
|
||||||
|
|
||||||
|
assert classify_media("image/jpeg") == MediaKind.IMAGE
|
||||||
|
assert classify_media("image/png") == MediaKind.IMAGE
|
||||||
|
assert classify_media("image/webp") == MediaKind.IMAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_media_video():
|
||||||
|
"""Test classification of video MIME types."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, classify_media
|
||||||
|
|
||||||
|
assert classify_media("video/mp4") == MediaKind.VIDEO
|
||||||
|
assert classify_media("video/quicktime") == MediaKind.VIDEO
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_media_audio():
|
||||||
|
"""Test classification of audio MIME types."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, classify_media
|
||||||
|
|
||||||
|
assert classify_media("audio/mpeg") == MediaKind.AUDIO
|
||||||
|
assert classify_media("audio/ogg") == MediaKind.AUDIO
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_media_document():
|
||||||
|
"""Test classification of document MIME types."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, classify_media
|
||||||
|
|
||||||
|
assert classify_media("application/pdf") == MediaKind.DOCUMENT
|
||||||
|
assert classify_media("text/plain") == MediaKind.DOCUMENT
|
||||||
|
assert classify_media("application/octet-stream") == MediaKind.DOCUMENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_heic_format():
|
||||||
|
"""Test HEIC format detection."""
|
||||||
|
from nanobot.channels.telegram_media import is_heic_format
|
||||||
|
|
||||||
|
assert is_heic_format("photo.heic") is True
|
||||||
|
assert is_heic_format("photo.HEIC") is True
|
||||||
|
assert is_heic_format("photo.heif") is True
|
||||||
|
assert is_heic_format("photo.jpg") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_image_jpeg_quality(tmp_path):
|
||||||
|
"""Test JPEG optimization reduces size with quality ladder."""
|
||||||
|
from nanobot.channels.telegram_media import optimize_image
|
||||||
|
|
||||||
|
# Create a large test image (3000x3000 RGB)
|
||||||
|
img = Image.new("RGB", (3000, 3000), color="red")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=95)
|
||||||
|
original_bytes = buf.getvalue()
|
||||||
|
original_size = len(original_bytes)
|
||||||
|
|
||||||
|
# Write to temp file
|
||||||
|
temp_file = tmp_path / "test.jpg"
|
||||||
|
temp_file.write_bytes(original_bytes)
|
||||||
|
|
||||||
|
# Optimize to 1MB max
|
||||||
|
optimized = optimize_image(str(temp_file), max_bytes=1_000_000)
|
||||||
|
|
||||||
|
# Should be smaller than original
|
||||||
|
assert len(optimized) < original_size
|
||||||
|
# Should be under limit
|
||||||
|
assert len(optimized) <= 1_000_000
|
||||||
|
# Should still be valid JPEG
|
||||||
|
assert optimized.startswith(b'\xff\xd8\xff')
|
||||||
|
|
||||||
|
|
||||||
|
def test_optimize_image_png_preserve_alpha(tmp_path):
|
||||||
|
"""Test PNG with alpha channel is preserved."""
|
||||||
|
from nanobot.channels.telegram_media import optimize_image
|
||||||
|
|
||||||
|
# Create PNG with alpha channel
|
||||||
|
img = Image.new("RGBA", (1000, 1000), color=(255, 0, 0, 128))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
original_bytes = buf.getvalue()
|
||||||
|
|
||||||
|
# Write to temp file
|
||||||
|
temp_file = tmp_path / "test.png"
|
||||||
|
temp_file.write_bytes(original_bytes)
|
||||||
|
|
||||||
|
optimized = optimize_image(str(temp_file), max_bytes=5_000_000)
|
||||||
|
|
||||||
|
# Should still be PNG (PNG magic bytes)
|
||||||
|
assert optimized.startswith(b'\x89PNG')
|
||||||
|
|
||||||
|
# Load and verify alpha channel preserved
|
||||||
|
img_opt = Image.open(io.BytesIO(optimized))
|
||||||
|
assert img_opt.mode == "RGBA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_media_success():
|
||||||
|
"""Test fetching media from remote URL."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from nanobot.channels.telegram_media import fetch_media
|
||||||
|
|
||||||
|
# Mock httpx response
|
||||||
|
mock_content = b"fake image data"
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = mock_content
|
||||||
|
mock_response.headers = {"content-type": "image/jpeg"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client:
|
||||||
|
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
content, mime = await fetch_media("https://example.com/image.jpg", max_bytes=10_000_000)
|
||||||
|
|
||||||
|
assert content == mock_content
|
||||||
|
assert mime == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_media_timeout():
|
||||||
|
"""Test fetch media handles timeout."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nanobot.channels.telegram_media import fetch_media
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client:
|
||||||
|
mock_client.return_value.__aenter__.return_value.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="timeout"):
|
||||||
|
await fetch_media("https://example.com/image.jpg", max_bytes=10_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_media_all_images():
|
||||||
|
"""Test grouping all images into album."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, group_media_for_album
|
||||||
|
|
||||||
|
media_items = [
|
||||||
|
("image1.jpg", MediaKind.IMAGE),
|
||||||
|
("image2.png", MediaKind.IMAGE),
|
||||||
|
("image3.jpeg", MediaKind.IMAGE),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
assert result["album"] == ["image1.jpg", "image2.png", "image3.jpeg"]
|
||||||
|
assert result["separate"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_media_all_videos():
|
||||||
|
"""Test grouping all videos into album."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, group_media_for_album
|
||||||
|
|
||||||
|
media_items = [
|
||||||
|
("video1.mp4", MediaKind.VIDEO),
|
||||||
|
("video2.mov", MediaKind.VIDEO),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
assert result["album"] == ["video1.mp4", "video2.mov"]
|
||||||
|
assert result["separate"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_media_mixed_types():
|
||||||
|
"""Test mixed media types sent separately."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, group_media_for_album
|
||||||
|
|
||||||
|
media_items = [
|
||||||
|
("image.jpg", MediaKind.IMAGE),
|
||||||
|
("video.mp4", MediaKind.VIDEO),
|
||||||
|
("audio.mp3", MediaKind.AUDIO),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
assert result["album"] == []
|
||||||
|
assert result["separate"] == ["image.jpg", "video.mp4", "audio.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_media_single_item():
|
||||||
|
"""Test single media item sent separately (not as album)."""
|
||||||
|
from nanobot.channels.telegram_media import MediaKind, group_media_for_album
|
||||||
|
|
||||||
|
media_items = [("image.jpg", MediaKind.IMAGE)]
|
||||||
|
|
||||||
|
result = group_media_for_album(media_items)
|
||||||
|
|
||||||
|
assert result["album"] == []
|
||||||
|
assert result["separate"] == ["image.jpg"]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# tests/test_telegram_suppress.py
|
||||||
|
import pytest
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suppressed_message_not_sent():
|
||||||
|
"""Test that messages with suppressed=True metadata are not sent to Telegram API."""
|
||||||
|
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the internal _app and bot directly (skip start())
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_bot = AsyncMock()
|
||||||
|
mock_app.bot = mock_bot
|
||||||
|
channel._app = mock_app
|
||||||
|
|
||||||
|
# Send a suppressed message
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="12345",
|
||||||
|
content="[HIDDEN] This should not be sent",
|
||||||
|
metadata={"suppressed": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Verify bot.send_message was NOT called
|
||||||
|
mock_bot.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_message_sent():
|
||||||
|
"""Test that normal messages are sent to Telegram API."""
|
||||||
|
|
||||||
|
config = MagicMock()
|
||||||
|
config.token = "test-token"
|
||||||
|
bus = MagicMock()
|
||||||
|
|
||||||
|
channel = TelegramChannel(config, bus)
|
||||||
|
|
||||||
|
# Mock the internal _app and bot directly (skip start())
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_bot = AsyncMock()
|
||||||
|
mock_app.bot = mock_bot
|
||||||
|
channel._app = mock_app
|
||||||
|
|
||||||
|
# Send a normal message
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="telegram",
|
||||||
|
chat_id="12345",
|
||||||
|
content="Normal message",
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(msg)
|
||||||
|
|
||||||
|
# Verify bot.send_message WAS called
|
||||||
|
mock_bot.send_message.assert_called_once()
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# tests/test_visibility_signing.py
|
||||||
|
import pytest
|
||||||
|
from nanobot.agent.visibility import sign_content, verify_signature, has_forged_marker, strip_all_hidden_markers
|
||||||
|
|
||||||
|
def test_sign_content_adds_hmac_marker():
|
||||||
|
"""Test that sign_content adds HMAC signature prefix."""
|
||||||
|
content = "HEARTBEAT_OK"
|
||||||
|
result = sign_content(content)
|
||||||
|
|
||||||
|
# Should start with [HIDDEN:{8 hex chars}]
|
||||||
|
assert result.startswith("[HIDDEN:")
|
||||||
|
assert "] " in result
|
||||||
|
marker_end = result.index("] ")
|
||||||
|
signature = result[8:marker_end] # Extract signature after "[HIDDEN:"
|
||||||
|
assert len(signature) == 8
|
||||||
|
assert all(c in "0123456789abcdef" for c in signature)
|
||||||
|
|
||||||
|
# Should contain original content
|
||||||
|
assert result.endswith("HEARTBEAT_OK")
|
||||||
|
|
||||||
|
def test_sign_content_is_deterministic():
|
||||||
|
"""Test that same content produces same signature."""
|
||||||
|
content = "Test message"
|
||||||
|
sig1 = sign_content(content)
|
||||||
|
sig2 = sign_content(content)
|
||||||
|
assert sig1 == sig2
|
||||||
|
|
||||||
|
def test_verify_signature_accepts_valid():
|
||||||
|
"""Test that verify_signature accepts validly signed content."""
|
||||||
|
signed = sign_content("Test message")
|
||||||
|
is_valid, clean = verify_signature(signed)
|
||||||
|
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Test message"
|
||||||
|
|
||||||
|
def test_verify_signature_rejects_invalid():
|
||||||
|
"""Test that verify_signature rejects forged signatures."""
|
||||||
|
forged = "[HIDDEN:deadbeef] Test message"
|
||||||
|
is_valid, clean = verify_signature(forged)
|
||||||
|
|
||||||
|
assert is_valid is False
|
||||||
|
assert clean == "Test message"
|
||||||
|
|
||||||
|
def test_verify_signature_handles_unsigned():
|
||||||
|
"""Test that unsigned content is marked as invalid."""
|
||||||
|
unsigned = "Plain message"
|
||||||
|
is_valid, clean = verify_signature(unsigned)
|
||||||
|
|
||||||
|
assert is_valid is False
|
||||||
|
assert clean == "Plain message"
|
||||||
|
|
||||||
|
def test_has_forged_marker_detects_invalid():
|
||||||
|
"""Test that has_forged_marker detects forged signatures."""
|
||||||
|
forged = "[HIDDEN:deadbeef] Content"
|
||||||
|
assert has_forged_marker(forged) is True
|
||||||
|
|
||||||
|
def test_has_forged_marker_accepts_valid():
|
||||||
|
"""Test that has_forged_marker accepts valid signatures."""
|
||||||
|
valid = sign_content("Content")
|
||||||
|
assert has_forged_marker(valid) is False
|
||||||
|
|
||||||
|
def test_has_forged_marker_ignores_unsigned():
|
||||||
|
"""Test that unsigned content is not flagged as forged."""
|
||||||
|
unsigned = "Plain content"
|
||||||
|
assert has_forged_marker(unsigned) is False
|
||||||
|
|
||||||
|
def test_strip_all_hidden_markers_removes_markers():
|
||||||
|
"""Test that strip_all_hidden_markers removes all markers."""
|
||||||
|
signed = sign_content("Message")
|
||||||
|
stripped = strip_all_hidden_markers(signed)
|
||||||
|
assert stripped == "Message"
|
||||||
|
|
||||||
|
forged = "[HIDDEN:deadbeef] Message"
|
||||||
|
stripped = strip_all_hidden_markers(forged)
|
||||||
|
assert stripped == "Message"
|
||||||
|
|
||||||
|
def test_system_prompt_includes_visibility_docs(tmp_path):
|
||||||
|
"""Test that system prompt documents visibility markers."""
|
||||||
|
from nanobot.agent.context import ContextBuilder
|
||||||
|
|
||||||
|
builder = ContextBuilder(workspace=tmp_path)
|
||||||
|
prompt = builder.build_system_prompt()
|
||||||
|
|
||||||
|
# Should document visibility markers
|
||||||
|
assert "[HIDDEN:" in prompt
|
||||||
|
assert "cryptographically signed" in prompt.lower()
|
||||||
|
assert "do not generate" in prompt.lower() or "don't generate" in prompt.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_suppress_mode_adds_signed_marker(tmp_path):
|
||||||
|
"""Test that suppress mode adds cryptographically signed markers."""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
|
||||||
|
# Mock provider
|
||||||
|
mock_provider = Mock()
|
||||||
|
mock_provider.default_model = "mock-model"
|
||||||
|
mock_provider.thinking_budget = 0
|
||||||
|
|
||||||
|
# Mock successful response
|
||||||
|
mock_response = LLMResponse(
|
||||||
|
content="Test response",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
mock_provider.chat = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
bus=bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
workspace=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process message with suppress_output=True
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="123",
|
||||||
|
content="Test message",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await loop._process_message(msg)
|
||||||
|
|
||||||
|
# Verify response has suppressed metadata
|
||||||
|
assert response.metadata.get("suppressed") is True
|
||||||
|
|
||||||
|
# Verify session contains signed marker
|
||||||
|
session = sessions.get_or_create("test:123")
|
||||||
|
assistant_messages = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistant_messages) > 0
|
||||||
|
|
||||||
|
last_msg = assistant_messages[-1]["content"]
|
||||||
|
assert last_msg.startswith("[HIDDEN:")
|
||||||
|
|
||||||
|
# Verify signature is valid
|
||||||
|
is_valid, clean = verify_signature(last_msg)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Test response"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_marker_accumulation_with_real_provider(tmp_path):
|
||||||
|
"""
|
||||||
|
CRITICAL TEST: Verify markers don't accumulate when model sees them in context.
|
||||||
|
|
||||||
|
This test uses a semi-realistic provider that sees the context and could
|
||||||
|
potentially copy markers, unlike pure mocks that don't see context at all.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
|
||||||
|
# Create a provider that SEES context and simulates potential copying behavior
|
||||||
|
class ContextAwareProvider:
|
||||||
|
"""Provider that sees context and could copy markers (simulating real LLM)."""
|
||||||
|
default_model = "test-model"
|
||||||
|
thinking_budget = 0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.call_count = 0
|
||||||
|
self.last_context = None
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
"""Get the default model."""
|
||||||
|
return self.default_model
|
||||||
|
|
||||||
|
async def chat(self, messages, **kwargs):
|
||||||
|
self.call_count += 1
|
||||||
|
self.last_context = messages
|
||||||
|
|
||||||
|
# Count markers in non-system messages (system prompt has 2 mentions in docs)
|
||||||
|
marker_count = sum(
|
||||||
|
msg.get("content", "").count("[HIDDEN:")
|
||||||
|
for msg in messages
|
||||||
|
if isinstance(msg.get("content"), str) and msg.get("role") != "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate model behavior: on first call (msg 2), sees 1 marker from msg 1
|
||||||
|
# The model should NOT copy it
|
||||||
|
if self.call_count == 2:
|
||||||
|
# Verify context has exactly 1 marker in assistant messages (from message 1)
|
||||||
|
assert marker_count == 1, f"Expected 1 marker in context, found {marker_count}"
|
||||||
|
|
||||||
|
# Always return clean response (good model behavior)
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Response {self.call_count}",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = ContextAwareProvider()
|
||||||
|
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=provider,
|
||||||
|
bus=bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
workspace=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use unique chat_id for this test to avoid pollution from previous runs
|
||||||
|
import time
|
||||||
|
test_chat_id = f"test_accumulation_{int(time.time()*1000)}"
|
||||||
|
|
||||||
|
# Message 1: suppress_output=True → should add signed marker
|
||||||
|
msg1 = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id=test_chat_id,
|
||||||
|
content="Hidden message 1",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
await loop._process_message(msg1)
|
||||||
|
|
||||||
|
# Verify message 1 has signed marker
|
||||||
|
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||||
|
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistant_msgs) == 1
|
||||||
|
msg1_content = assistant_msgs[0]["content"]
|
||||||
|
assert msg1_content.startswith("[HIDDEN:")
|
||||||
|
is_valid, clean = verify_signature(msg1_content)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Response 1"
|
||||||
|
|
||||||
|
# Count markers in session after message 1
|
||||||
|
marker_count_1 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||||
|
assert marker_count_1 == 1, f"Expected 1 marker after msg1, found {marker_count_1}"
|
||||||
|
|
||||||
|
# Message 2: Normal message (context includes message 1 with marker)
|
||||||
|
msg2 = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id=test_chat_id,
|
||||||
|
content="Normal message 2",
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
|
||||||
|
await loop._process_message(msg2)
|
||||||
|
|
||||||
|
# Verify message 2 response does NOT start with [HIDDEN: (model didn't copy)
|
||||||
|
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||||
|
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistant_msgs) == 2
|
||||||
|
msg2_content = assistant_msgs[1]["content"]
|
||||||
|
assert not msg2_content.startswith("[HIDDEN:"), f"Message 2 should not start with [HIDDEN:, got: {msg2_content}"
|
||||||
|
|
||||||
|
# Verify still only 1 marker in session (no accumulation)
|
||||||
|
marker_count_2 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||||
|
assert marker_count_2 == 1, f"Expected 1 marker after msg2, found {marker_count_2} (ACCUMULATION DETECTED)"
|
||||||
|
|
||||||
|
# Message 3: Another suppress_output=True → should add SECOND signed marker
|
||||||
|
msg3 = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id=test_chat_id,
|
||||||
|
content="Hidden message 3",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
await loop._process_message(msg3)
|
||||||
|
|
||||||
|
# Verify message 3 has signed marker
|
||||||
|
session = sessions.get_or_create(f"test:{test_chat_id}")
|
||||||
|
assistant_msgs = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistant_msgs) == 3
|
||||||
|
msg3_content = assistant_msgs[2]["content"]
|
||||||
|
assert msg3_content.startswith("[HIDDEN:")
|
||||||
|
is_valid, clean = verify_signature(msg3_content)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Response 3"
|
||||||
|
|
||||||
|
# Verify exactly 2 markers in session (one from msg1, one from msg3)
|
||||||
|
marker_count_3 = sum(m.get("content", "").count("[HIDDEN:") for m in session.messages if isinstance(m.get("content"), str))
|
||||||
|
assert marker_count_3 == 2, f"Expected 2 markers after msg3, found {marker_count_3}"
|
||||||
|
|
||||||
|
# CRITICAL: Verify no double/triple markers like "[HIDDEN: [HIDDEN: [HIDDEN: message"
|
||||||
|
for msg in session.messages:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if isinstance(content, str) and "[HIDDEN:" in content:
|
||||||
|
# Count occurrences of [HIDDEN: pattern in this single message
|
||||||
|
hidden_count = content.count("[HIDDEN:")
|
||||||
|
assert hidden_count == 1, f"Message has {hidden_count} [HIDDEN: markers (accumulation): {content[:100]}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forged_marker_triggers_rejection(tmp_path):
|
||||||
|
"""Test that forged markers trigger rejection and retry."""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
|
||||||
|
# Mock provider
|
||||||
|
mock_provider = Mock()
|
||||||
|
mock_provider.default_model = "mock-model"
|
||||||
|
mock_provider.thinking_budget = 0
|
||||||
|
|
||||||
|
# First response: model tries to forge marker
|
||||||
|
forged_response = LLMResponse(
|
||||||
|
content="[HIDDEN:deadbeef] Forged message",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second response: clean response after correction
|
||||||
|
clean_response = LLMResponse(
|
||||||
|
content="Clean message",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_provider.chat = AsyncMock(side_effect=[forged_response, clean_response])
|
||||||
|
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
bus=bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
workspace=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process message with suppress_output=True
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="test",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="123",
|
||||||
|
content="Test message",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await loop._process_message(msg)
|
||||||
|
|
||||||
|
# Verify provider.chat was called twice (initial + retry)
|
||||||
|
assert mock_provider.chat.call_count == 2
|
||||||
|
|
||||||
|
# Verify second call included correction message
|
||||||
|
second_call_messages = mock_provider.chat.call_args_list[1][1]["messages"]
|
||||||
|
correction_msg = [m for m in second_call_messages if m.get("role") == "user" and "rejected" in m.get("content", "").lower()]
|
||||||
|
assert len(correction_msg) > 0
|
||||||
|
|
||||||
|
# Verify final response uses clean content (not forged)
|
||||||
|
session = sessions.get_or_create("test:123")
|
||||||
|
assistant_messages = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
last_msg = assistant_messages[-1]["content"]
|
||||||
|
|
||||||
|
# Should be signed version of "Clean message", not "Forged message"
|
||||||
|
is_valid, clean = verify_signature(last_msg)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Clean message"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_message_handler_uses_signed_markers(tmp_path):
|
||||||
|
"""Test that _process_system_message uses signed markers in suppress mode."""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
|
||||||
|
# Mock provider
|
||||||
|
mock_provider = Mock()
|
||||||
|
mock_provider.default_model = "mock-model"
|
||||||
|
mock_provider.thinking_budget = 0
|
||||||
|
mock_response = LLMResponse(
|
||||||
|
content="System response",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
mock_provider.chat = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
bus=bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
workspace=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process system message with suppress_output=True
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id="subagent",
|
||||||
|
chat_id="test:123",
|
||||||
|
content="[Subagent completed] Result: OK",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await loop._process_system_message(msg)
|
||||||
|
|
||||||
|
# Verify response has suppressed metadata
|
||||||
|
assert response.metadata.get("suppressed") is True
|
||||||
|
|
||||||
|
# Verify session contains signed marker
|
||||||
|
session = sessions.get_or_create("test:123")
|
||||||
|
assistant_messages = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistant_messages) > 0
|
||||||
|
|
||||||
|
last_msg = assistant_messages[-1]["content"]
|
||||||
|
assert last_msg.startswith("[HIDDEN:")
|
||||||
|
|
||||||
|
# Verify signature is valid
|
||||||
|
is_valid, clean = verify_signature(last_msg)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "System response"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_message_handler_rejects_forged_markers(tmp_path):
|
||||||
|
"""Test that _process_system_message rejects forged markers."""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
bus = MessageBus()
|
||||||
|
sessions = SessionManager(tmp_path)
|
||||||
|
|
||||||
|
# Mock provider
|
||||||
|
mock_provider = Mock()
|
||||||
|
mock_provider.default_model = "mock-model"
|
||||||
|
mock_provider.thinking_budget = 0
|
||||||
|
|
||||||
|
# First response: model tries to forge marker
|
||||||
|
forged_response = LLMResponse(
|
||||||
|
content="[HIDDEN:deadbeef] Forged system message",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second response: clean response after correction
|
||||||
|
clean_response = LLMResponse(
|
||||||
|
content="Clean system response",
|
||||||
|
tool_calls=[],
|
||||||
|
reasoning_content=None
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_provider.chat = AsyncMock(side_effect=[forged_response, clean_response])
|
||||||
|
|
||||||
|
# Create agent loop
|
||||||
|
loop = AgentLoop(
|
||||||
|
provider=mock_provider,
|
||||||
|
bus=bus,
|
||||||
|
session_manager=sessions,
|
||||||
|
workspace=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process system message with suppress_output=True
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id="subagent",
|
||||||
|
chat_id="test:456",
|
||||||
|
content="[Subagent completed] Result: OK",
|
||||||
|
metadata={"suppress_output": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await loop._process_system_message(msg)
|
||||||
|
|
||||||
|
# Verify provider.chat was called twice (initial + retry)
|
||||||
|
assert mock_provider.chat.call_count == 2
|
||||||
|
|
||||||
|
# Verify second call included correction message
|
||||||
|
second_call_messages = mock_provider.chat.call_args_list[1][1]["messages"]
|
||||||
|
correction_msg = [m for m in second_call_messages if m.get("role") == "user" and "rejected" in m.get("content", "").lower()]
|
||||||
|
assert len(correction_msg) > 0
|
||||||
|
|
||||||
|
# Verify final response uses clean content (not forged)
|
||||||
|
session = sessions.get_or_create("test:456")
|
||||||
|
assistant_messages = [m for m in session.messages if m.get("role") == "assistant"]
|
||||||
|
last_msg = assistant_messages[-1]["content"]
|
||||||
|
|
||||||
|
# Should be signed version of "Clean system response", not "Forged system message"
|
||||||
|
is_valid, clean = verify_signature(last_msg)
|
||||||
|
assert is_valid is True
|
||||||
|
assert clean == "Clean system response"
|
||||||
Reference in New Issue
Block a user