Compare commits

...
Author SHA1 Message Date
code-server 74601c833f feat(config): enable memory tool by default
Build Nanobot OAuth / build (pull_request) Successful in 47s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Build Nanobot OAuth / build (push) Successful in 53s
Build Nanobot OAuth / cleanup (push) Successful in 1s
- Change enable_memory_tool default from False to True
- Update tests to reflect new default behavior
- Since this is a personal instance with no other users, opt-out
  makes more sense than opt-in for a requested feature
2026-02-28 20:51:58 +00:00
code-server a6d43a0d01 feat(cli): wire enable_memory_tool config to AgentLoop
- Pass config.tools.enable_memory_tool to all AgentLoop instances
- Applied to gateway, agent, and cron commands
- Completes config-to-runtime wiring
2026-02-28 20:51:58 +00:00
code-server 98debd5f5f feat(agent): add conditional registration for memory tool
- Add enable_memory_tool parameter to AgentLoop.__init__
- Memory tool only registered when flag is True
- 2 new integration tests in test_memory_integration.py, all passing
2026-02-28 20:51:51 +00:00
code-server 4a0b5c6709 feat(config): add enable_memory_tool flag to ToolsConfig
- Add enable_memory_tool field to ToolsConfig (default: False)
- Supports both snake_case and camelCase variants
- 3 new tests in test_memory_config.py, all passing
2026-02-28 20:51:46 +00:00
code-server 5c4b942107 feat(memory): implement insert, delete, rename commands
- Insert: add text at specific line number with validation
- Delete: remove files or directories recursively
- Rename: move/rename with collision detection
- All commands follow path security and CLIResult pattern
- 6 new tests in test_memory_commands.py, all passing
- Total 34 memory tests passing
2026-02-28 20:51:46 +00:00
code-server a8f3a874c8 feat(memory): implement str_replace command 2026-02-28 20:51:46 +00:00
code-server 8fee0f4710 feat(memory): implement create command 2026-02-28 20:51:46 +00:00
code-server 8f28e3eb9c feat(memory): implement view command for directories 2026-02-28 20:51:46 +00:00
code-server 23fbd359bb feat(memory): implement view command for files 2026-02-28 20:51:46 +00:00
code-server 77ef8a0d1e feat(memory): implement path security validation 2026-02-28 20:51:46 +00:00
code-server 311e7b5fc6 feat(memory): add MemoryTool20250818 base structure 2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 49d39692bc Replace message chunking with upstream's proven implementation
- Switch from sentence-boundary splitting to upstream's simpler approach
- Uses max_len=4000 (safer buffer vs 4096 limit)
- Split priority: line breaks → spaces → hard cut
- Battle-tested implementation from HKUDS/nanobot upstream
- Simpler, more maintainable code
- Works better for both prose and code/logs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 73d4e89fb7 Fix Telegram message chunking for messages >4096 chars
- Add _send_text_chunks method to split messages at sentence boundaries
- Messages exceeding Telegram's 4096 character limit now send as multiple messages
- Per design doc: docs/plans/2026-02-27-openclaw-telegram-features-design.md
- All new tests pass (4/4), no regressions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 9772a87cfe Snapshot: All native Anthropic tools implemented
Complete implementation of all three native Anthropic tools:
- bash_20250124: Shell command execution
- text_editor_20250124: File editing operations
- computer_20251124: VNC desktop control (all 17 actions)

Includes provider updates, test improvements, and registry changes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 5ed760df84 Add all 17 native computer_20251124 actions to VNC tool
Implemented missing actions reported by nanobot:
- scroll (with direction, amount, modifier support)
- zoom (region cropping)
- triple_click, double_click, middle_click
- left_mouse_down, left_mouse_up, left_click_drag
- hold_key (with duration)
- paste (VNC clipboard)
- wait (with duration)

All actions tested and working via VNC at 172.17.0.1::5900.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 d27db8168c fix(tools): add duck typing to registry execute() method
Registry now supports executing both native Anthropic tools (via __call__)
and function tools (via execute). Native tools return ToolResult/CLIResult
objects instead of strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:46 +00:00
code-serverandClaude Sonnet 4.5 72fcfdd148 test(agent): verify native tools registration
Add test confirming BashTool20250124, EditTool20250728, and
ComputerTool20251124 are registered in AgentLoop on initialization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 4197a72fab test(agent): add test for media tracking
Verify that screenshots from computer tool are tracked in
media_paths_for_turn and included in OutboundMessage.media.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 aa91509b43 fix(agent): handle ToolResult in system message handler
Apply same result type handling logic to _process_system_message
to support native tools in subagent/system contexts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 f060ba9d50 feat(tools): remove ExecTool and EditFileTool
Replaced by BashTool20250124 and EditTool20250728 which provide
better functionality via model-trained behaviors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 a4fcdf8804 feat(agent): register native tools in agent loop
BashTool20250124, EditTool20250728, and ComputerTool20251124 are now
automatically registered alongside existing function tools.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 f01e6b55c7 feat(agent): add media tracking for screenshots
Screenshots from computer tool are saved to disk and included in
OutboundMessage.media for channel delivery to users.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 8bad8293b0 test(agent): add tests for ToolResult handling in agent loop
Comprehensive tests for:
- ToolResult with output field
- ToolResult with error field
- ToolResult with base64_image field
- CLIResult handling
- Legacy string results (backward compatibility)
- Combined output and error fields

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 cf5360a346 feat(agent): add ToolResult handling to agent loop
Agent loop now processes ToolResult and CLIResult from native tools,
while maintaining backward compatibility with string results.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 3b068bea5e fix(tests): update CLIResult test for new signature
CLIResult now requires exit_code, output, and error fields after
EditTool implementation. Update test to match new signature.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 cb7d39b221 feat(tools): implement ComputerTool20251124
Add computer_20251124 for VNC desktop control. Supports keyboard,
mouse, and screenshots via vncdotool.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 e3ce706f65 test(tools): add missing tests for EditTool20250728
Add test_edit_tool_requires_absolute_path and test_edit_tool_to_params
to complete test coverage per specification.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 d5f9cf7fc7 feat: implement EditTool20250728 with view/create/str_replace/insert
- Add text_editor_20250728 native tool
- Support 4 commands: view, create, str_replace, insert
- Require absolute paths for all operations
- Enforce str_replace uniqueness (count == 1)
- Format view output with line numbers
- Return CLIResult with exit_code/output/error
- All 5 tests passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 91d79cec71 feat(tools): implement BashTool20250124 with persistent session
Add Anthropic's native bash_20250124 tool with:
- Persistent bash subprocess (_BashSession)
- Sentinel-based output reading (<<BASH_COMMAND_DONE>>)
- 120s timeout per command
- Session restart capability
- Full test coverage (4 tests)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 ed64ba04e8 test(provider): improve beta flag test assertion
Make test more specific by expecting exact sorted order
instead of checking both permutations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 81c647c28a feat(provider): add beta flag collection for native tools
Extract beta_flag from tool objects before conversion and add
to API request headers. Supports multiple flags via comma-join.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 8e435b635d feat(provider): support native tools in API conversion
_convert_tools_to_anthropic now passes through native tool format
(bash_20250124, etc.) while still converting function tools.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 926a3a7526 feat(tools): add duck typing support to ToolRegistry
Registry now supports both function tools (to_schema) and native
tools (to_params) via hasattr checks. Enables mixed tool types.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 0afdaa5e7e test(tools): add complete coverage for base classes
Add tests for CLIResult and ToolError.
Expand ToolResult test to cover system field.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 10f48acd4a feat(tools): add Anthropic native tool base classes
Add BaseAnthropicTool, ToolResult, CLIResult, and ToolError.
These support native tools with version-coupled behaviors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 203ff48acc Add vncdotool dependency for computer tool
Required for VNC-based computer_20251124 implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 2c4be5b9f7 Add .worktrees/ to .gitignore
Preparing for isolated feature development using git worktrees.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 bccbc2cf1e Add design: Native Anthropic tools integration
Design for integrating bash_20250124, text_editor_20250728, and
computer_20251124 native tools into nanobot. These tools leverage
model-trained behaviors instead of instruction-following.

Key approach: Duck-typed registry supporting both function tools
and native tools, with beta flag management and ToolResult handling.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 8c25385c39 fix: preserve filenames for documents and audio in Telegram
Root cause: send_document and send_audio were receiving raw bytes
without filename metadata, causing Telegram to use generic
"application.octet-stream" name.

Solution: Extract filename from path and pass via filename parameter
to send_document/send_audio and their InputMedia counterparts.

Images/videos unaffected as Telegram infers names from content type.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 94134ce174 fix: add missing Path import to telegram.py
The _send_with_media method uses Path but the import was missing from the top-level imports, causing "name 'Path' is not defined" error.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-serverandClaude Sonnet 4.5 49a7adc03b feat: complete media implementation with agent tools layer
Add missing media parameter to message tool, enabling agents to send media attachments via the message() tool.

Changes:
- message.py: Add media parameter to tool schema and execute() method
- telegram.py: Restore _send_with_media() method with album support

This completes the three-layer media architecture:
- Agent Loop: message tool now accepts media parameter
- Provider Layer: OutboundMessage carries media list
- Telegram Channel: _send_with_media processes and sends media

Fixes the root cause where agents had no way to specify media attachments, resulting in invented [file: ...] syntax in message content.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:39 +00:00
code-server 297308d6db Remove accidentally committed system files 2026-02-28 20:51:29 +00:00
code-server 08add97bdb Revert broken media implementation - incomplete, missing agent tools layer 2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 3e6a69d0a8 fix: address resource leaks and caption logic in media sending
- Add context manager to PIL Image.open() to prevent file handle leaks
- Fix caption logic for separate media (track first non-album item)
- Improve exception handling with size checks and specific types
- Add constants for magic numbers (REMOTE_MEDIA_SIZE_LIMIT)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 ecc7dc98c6 feat: integrate media sending with album support
Processes local files and remote URLs
Optimizes images automatically
Groups 2+ images/videos into albums
Handles caption overflow (>1024 chars)
Routes to correct Telegram API methods

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 c1e754aebc feat: add album grouping logic
Groups 2+ images or 2+ videos into albums
Mixed types or single items sent separately

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 a26af533f6 feat: add remote media fetch with httpx
Downloads from URLs with 10s timeout
Detects MIME from response headers or content
Enforces size limit

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 b2a464c49a fix: remove fake async and improve image optimization
- Remove async keyword from synchronous image processing
- Always copy images to avoid mutation issues
- Fix PNG fallback to handle LA (grayscale+alpha) mode
- Use pytest tmp_path fixture for cleaner tests
- Add TELEGRAM_PHOTO_SIZE_LIMIT constant

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 08469dfcd0 feat: add image optimization with quality ladder
HEIC → JPEG conversion via pillow-heif
PNG with alpha preserved, compressed [6-9]
JPEG optimized via size [2048-800] × quality [80-40] grid

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 9bee169554 feat: add HEIC format detection
Checks for .heic and .heif extensions
Foundation for HEIC to JPEG conversion

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 7a5cf3cced feat: add media kind classification
Classifies MIME types into IMAGE/VIDEO/AUDIO/DOCUMENT
Used for routing to correct Telegram API methods

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 d6a1b04a9a fix: clean up imports and improve error handling in telegram_media
- Remove unused imports (Path, pytest)
- Add logging for magic detection failures
- Improve test coverage for edge cases

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 0b1dbe82b3 feat: add MIME detection with python-magic and extension fallback
Priority: magic sniff > extension > fallback
Handles JPEG, PNG, video, and unknown files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 baf540f70b feat: add pillow-heif and python-magic dependencies
Required for media handling:
- pillow-heif: HEIC to JPEG conversion
- python-magic: MIME type detection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:29 +00:00
code-serverandClaude Sonnet 4.5 65a82f5ce5 fix: reduce heartbeat idle threshold to 20m to avoid hour-long gaps
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:21 +00:00
code-serverandClaude Sonnet 4.5 2af05caa68 feat: auto-start moltbook polling loop on gateway startup
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:21 +00:00
code-serverandClaude Sonnet 4.5 dd854cb245 fix: clarify subagent message tool sends to main agent, not user
Updated descriptions to make it clear the message tool sends to the
main agent (who processes and decides how to respond), not directly
to the user.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:21 +00:00
code-serverandClaude Sonnet 4.5 6e106109aa feat: add message tool for subagents with metadata preservation
Subagents can now send messages to users via the message tool.
Messages are routed through the main agent via the bus and preserve
metadata (e.g. suppress_output) from the originating message.

Changes:
1. Created SubagentMessageTool that creates InboundMessages
2. Publishes to bus with preserved metadata
3. Registered in subagent tool registry
4. Updated subagent system prompt to mention message capability

This allows subagents to communicate findings during execution while
respecting suppression flags (e.g. heartbeat subagents won't spam Telegram).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:21 +00:00
code-serverandClaude Sonnet 4.5 5f863af5e0 fix: propagate suppress_output metadata to subagent announcements
When a subagent completes and announces its result, it now inherits the
metadata (including suppress_output) from the original message that spawned it.

Changes:
1. SpawnTool.set_context() now accepts metadata parameter
2. SubagentManager.spawn() now accepts origin_metadata parameter
3. Origin dict now includes 'metadata' field
4. _announce_result() includes metadata when creating InboundMessage
5. All set_context() calls pass msg.metadata

This fixes the bug where heartbeat suppression was lost when subagents
announced their results - the subagent announcement would be visible on
Telegram even though the heartbeat itself was suppressed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:21 +00:00
code-serverandClaude Sonnet 4.5 c13c8c3ad5 fix: wait_for_subagents now works for top-level subagents
Store results in _task_results for ALL subagents, regardless of origin.
Then only announce to bus if it's a top-level subagent (not a child).

This fixes the bug where wait_for_subagents would return 'No result found'
for subagents spawned from the main telegram session, because their results
went to the bus instead of _task_results.

Now:
- All subagents store results in _task_results (so wait_for can find them)
- Child subagents (origin[channel] == 'subagent') return early (no announcement)
- Top-level subagents continue and announce via bus (so main agent gets notified)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 6aab2745c1 fix: heartbeat idle detection now correctly identifies real user messages
The heartbeat service was incorrectly counting ALL messages with role="user"
as user activity, including system-generated messages (heartbeat prompts,
subagent announcements). This caused the idle detection to never trigger
because heartbeat's own messages were counted as user activity.

Changes:
1. Store sender_id in session messages (loop.py)
   - Added sender_id=msg.sender_id to session.add_message() call
   - Allows distinguishing real user messages from system-generated ones

2. Filter by sender_id in heartbeat idle detection (service.py)
   - Real Telegram messages have sender_id like "239824268|username"
   - System messages via process_direct have sender_id="user" (hardcoded)
   - Heartbeat now skips messages with sender_id="user"
   - Backwards compatible: messages without sender_id are treated as real

This is a robust, source-based solution that checks how messages are
CREATED rather than pattern-matching their content.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 6dcbf8d40b fix: update tests to handle signed visibility markers
Updated existing tests to work with cryptographic visibility markers:

1. test_agent_loop_metadata.py:
   - Updated test_suppress_mode_adds_hidden_prefix to verify [HIDDEN:signature] format
   - Added validation for 8-character hex signature
   - Updated test_normal_mode_no_hidden_prefix to check for [HIDDEN: prefix

2. test_idle_heartbeat_integration.py:
   - Updated test_idle_heartbeat_end_to_end to search for [HIDDEN: prefix
   - Added signature format validation (8-char hex)
   - Updated docstring to reflect signed markers

All 86 tests now pass (excluding OAuth tests as specified).
The changes maintain backwards compatibility while enforcing
the new cryptographic signing requirement.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 05222a9eca feat: apply signed markers to system message handler
Extends signed marker support to _process_system_message() for subagent
announcements. Ensures consistency across all suppress mode paths.

- Add forgery detection in system message loop
- Sign content before saving to session when suppressed
- Return unsuppressed content with suppressed metadata
- Add comprehensive tests for system message signing and forgery rejection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 c4b378236e feat: add forgery detection and rejection in agent loop
Detects forged [HIDDEN:*] markers in model output and triggers rejection
with retry. Includes correction message to model and fallback stripping
if model persists. Prevents accumulation from model forgery attempts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 ce64338865 feat: use signed markers in suppress mode (_process_message)
Replaces simple [HIDDEN] prefix with cryptographically signed markers
in _process_message(). Strips any forged markers from model output
before signing with system key.

Includes comprehensive accumulation test that verifies:
- Markers are properly signed when suppress_output=True
- Model seeing markers in context doesn't copy them
- No accumulation of markers across multiple messages
- Each hidden message gets exactly one signed marker

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 aba9a23cd9 test: add system prompt visibility docs test
Verifies that system prompt includes documentation about cryptographically
signed visibility markers.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 9886d95934 docs: add visibility markers explanation to system prompt
Documents the purpose of [HIDDEN:{sig}] markers and explicitly forbids
model from generating them. Sets clear expectations for rejection behavior.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:51:14 +00:00
code-serverandClaude Sonnet 4.5 64bcd922da fix: use constant-time comparison and flexible whitespace in visibility markers
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 d9f82d5cb4 feat: add signature verification and marker stripping
Implements verify_signature() to check HMAC validity, has_forged_marker()
to detect forgery attempts, and strip_all_hidden_markers() for cleanup.
Comprehensive test coverage for all verification scenarios.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 8808fb1f46 feat: add HMAC signature generation for visibility markers
Implements sign_content() to cryptographically sign message content
with HMAC-SHA256 (8-char truncated). This prevents models from forging
visibility markers as they cannot generate valid signatures.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 dae2d81406 fix: ensure integration test uses isolated session storage
Override SessionManager.sessions_dir to use tmp_path, preventing
session accumulation across test runs.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 a1184cadd7 fix: improve integration test cleanup and assertions
- Use tmp_path fixture for automatic cleanup
- Assert exactly 1 heartbeat message (not > 0)
- Use test-specific session key instead of production key

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 d00ee13c47 fix: apply [HIDDEN] prefix before saving to session
Bug discovered during integration test: suppress mode was
prefixing content AFTER saving to session, so session stored
unprefixed content while only the outbound message was prefixed.

Fix: Move suppress check before session save and use prefixed
content when adding to session messages.

test: add end-to-end integration test for idle heartbeat

Verifies complete flow:
- Idle detection triggers heartbeat
- Heartbeat runs in main session
- Output is suppressed with [HIDDEN] prefix
- Session contains full context

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 1d473e3f38 fix: linting in gateway command
Auto-fix import sorting and whitespace issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 8a601fce77 feat: wire up idle heartbeat in gateway command
- Update callback to pass metadata and use telegram session
- Pass session manager to HeartbeatService
- Configure 30min idle threshold

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:59 +00:00
code-serverandClaude Sonnet 4.5 d5283e9b10 fix: address linting issues in heartbeat idle detection
- Add future annotations import for type hints
- Add TYPE_CHECKING import for SessionManager forward reference
- Remove unused response variable in _tick method
- Fix whitespace in docstring

Tests directory changes (removed unused imports) remain in working tree
but are not committed due to .gitignore.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:49 +00:00
code-serverandClaude Sonnet 4.5 236d67f690 feat: add idle detection to heartbeat service
Heartbeat now:
- Checks last user message timestamp in target session
- Only triggers if >30min elapsed since last user message
- Passes suppress_output metadata to callback
- Removes HEARTBEAT_OK check (unnecessary with suppress mode)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:49 +00:00
code-serverandClaude Sonnet 4.5 2159dd49f1 fix: stop typing indicator before checking suppression
Fixes bug where suppressed messages would leave typing indicator
running forever. Now _stop_typing() is called before early return.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 55ed9af08e feat: add suppression support to Telegram channel
Messages with metadata['suppressed']=True are logged but not sent
to Telegram API, enabling heartbeat to run without spamming user.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 47c7e9412f feat: implement suppress mode in agent loop
When metadata['suppress_output']=True:
- Adds [HIDDEN] prefix to content saved in session
- Sets metadata['suppressed']=True for channel handler
- Allows explicit message() tool calls to bypass suppression

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 b54c79003e feat: add metadata parameter to AgentLoop.process_direct()
Allows passing metadata through process_direct() for features like
suppress mode in heartbeat.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 38f33f51a8 Refactor hooks config to remove redundancies
- Remove singular `token` field, keep only `tokens` dict
- Simplify `resolve_token()` and `has_tokens` logic
- Use finally block for correlation cleanup in server
- Simplify `_resolve_auth()` to eliminate duplicate pattern
- Remove redundant `has_tokens` check from CLI (server checks internally)
- Update tests to remove backward-compat test cases

Lines removed: ~30
Tests passing: 14/14

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 e64dfbb40c test: end-to-end hooks integration tests
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 41f8381138 feat: wire hooks server + hook channel into CLI startup
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:50:42 +00:00
code-serverandClaude Sonnet 4.5 171b63bd18 feat(hooks): rewrite server to use bus + correlation
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:49:41 +00:00
code-serverandClaude Sonnet 4.5 b772dbb0e6 feat(channels): add hook channel
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:49:41 +00:00
code-serverandClaude Sonnet 4.5 6286e04f7e feat(config): named tokens for hooks
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:49:41 +00:00
code-serverandClaude Sonnet 4.5 d107e5826d feat(agent): carry metadata through all OutboundMessage paths, add hook prefix
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:48:55 +00:00
code-serverandClaude Sonnet 4.5 54c0b6f71e feat(manager): resolve correlation in outbound dispatch
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:48:55 +00:00
code-serverandClaude Sonnet 4.5 12135bfc4e feat(bus): add correlation store for request-response
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:48:32 +00:00
code-serverandClaude Sonnet 4.5 816368e4f9 MessageTool writes to session; remove max_messages limit
- MessageTool now writes sent messages to session history via SessionManager
- Agent loop wires SessionManager into MessageTool constructor
- Session.get_history() returns full history (removed max_messages limit)
  Server-side context editing API handles trimming, so we send full history

This ensures messages sent via the message() tool (e.g., from heartbeat forks)
are visible in the main conversational agent's context.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:48:21 +00:00
code-serverandClaude Sonnet 4.5 993c05efdc session: remove max_messages slicing from get_history()
Sending a slice of history can cut in the middle of a tool chain, causing
'unexpected tool_use_id' 400 errors when the API receives an orphaned
tool_result without its preceding assistant tool_use block.

The server-side context editing API (clear_tool_uses_20250919) handles
trimming safely at token thresholds while respecting tool chain boundaries.
Let the server manage context length; send the full history from the client.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:47:39 +00:00
code-serverandClaude Sonnet 4.5 4c32ecf114 Store full tool chain in session; replace manual consolidation with server-side context editing
- session/manager.py: add add_raw_message() to persist tool chain messages;
  get_history() now passes all API-relevant fields (tool_calls, tool_call_id,
  name, reasoning_content) instead of stripping to role+content only

- loop.py: after each turn, save the complete message sequence (tool_use,
  tool_results, thinking blocks, final reply) instead of just the final text;
  remove automatic consolidation trigger — server-side context editing handles
  the token window now; _consolidate_memory (runs on /new) updated to handle
  list content, tool messages, and new message formats

- anthropic_oauth.py: add context_management parameter to chat() and
  _make_request(); log context edits applied by Anthropic; log context_mgmt
  strategies in request log line

- oauth_utils.py: add context-management-2025-06-27 beta header

- base.py, litellm_provider.py: propagate context_management parameter

CONTEXT_MANAGEMENT config on every agent call:
  - clear_thinking_20251015 keep="all" → preserve all thinking blocks for cache
  - clear_tool_uses_20250919 trigger=80k tokens, keep=5 recent tool uses

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:47:39 +00:00
code-serverandClaude Sonnet 4.5 b36e9f8581 fix(subagent): don't inherit Opus from main loop — use Sonnet default
SubagentManager was receiving model=self.model (Opus) from the main
agent loop, overriding the intended "claude-sonnet-4-6" fallback in
SubagentManager.__init__. Subagents should default to Sonnet unless
explicitly overridden via the spawn tool call.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:47:25 +00:00
code-serverandClaude Sonnet 4.5 5b2eb77ff2 fix(subagent): restore f-string, add exec date first-action rule
- Restore f-string prefix so {self.workspace} interpolates correctly
- Add task parameter back to _build_subagent_prompt signature
- Instruct subagents to run exec date as first action (avoids
  injecting dynamic timestamp into system prompt that busts cache)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:47:25 +00:00
code-serverandClaude Sonnet 4.5 2f18d2d93c feat: load KNOWLEDGE.md instead of MEMORY.md into system prompt
MEMORY.md is updated by the Haiku consolidator after every session,
changing the system prompt and busting the 1h cache. Replace it with
KNOWLEDGE.md — a static, manually-curated file that stays stable.
MEMORY.md remains accessible to the agent via read/grep tools.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:47:25 +00:00
code-serverandClaude Sonnet 4.5 bc1b1cd61d fix: store current_message in session to preserve time prefix for cache hits
session.add_message was storing raw msg.content without the [Current time: ...]
prefix, causing cache key mismatches on subsequent turns since the API received
the prefixed version but history replayed the raw version.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
code-serverandClaude Sonnet 4.5 99802a211c fix: move current time from system prompt to user message to enable cache hits
The system prompt included a minute-resolution timestamp that changed every
call, busting the 1h cache on every request. Move current time to a [Current
time: ...] prefix on each user message instead, keeping the system prompt
static for cache hits. Also clarify the time-gap notice text.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
code-serverandClaude Sonnet 4.5 e8df5ca8fb feat: cache conversation history + skip Reflect prompt when thinking active
- Cache last user message on every API call (5m TTL) so full conversation
  history is a cache read on subsequent turns
- Skip "Reflect on the results" interleave prompt when thinking_budget > 0
  since extended thinking already handles reflection internally; keeps
  message caching valid across tool iterations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
code-serverandClaude Sonnet 4.5 28abf4128e feat: enable prompt caching for system prompt and tools (1h TTL)
Cache system prompt and tool definitions on every API call to reduce
quota burn. Uses 1-hour TTL so context stays warm across conversations.
Also logs cache_write/cache_read token counts in response log line.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
code-serverandClaude Sonnet 4.5 57026ddd1e fix: register WaitForSubagentsTool in main AgentLoop so it's available in live sessions
Previously wait_for_subagents was only registered inside _run_subagent
(spawned orchestrators). Main conversation agent had no access to it.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
wylabandcode-server 8863655332 feat: prepend time-gap notice to user message when >5 min elapsed (#10)
## Summary

- Adds time-gap awareness to `_process_message` in `nanobot/agent/loop.py`
- When >5 minutes have passed since the last user message in a session, prepends `[SYSTEM ANNOUNCEMENT: X minutes/hours/days have elapsed since last user message]` to the current message content
- Keeps the LLM aware of real time elapsed between conversation turns

## Implementation Details

- Walks `session.messages` in reverse to find the last user message timestamp
- Uses `datetime.fromisoformat()` to parse the stored ISO timestamps
- Threshold: 300s (5 min) → formats as minutes, hours, or days
- Malformed timestamps silently skipped (try/except)
- Note: `session.add_message("user", ...)` runs **after** `build_messages`, so the reversed walk finds the *previous* user message — no off-by-one issue
- Only affects `_process_message`; `_process_system_message` (subagent announces) is unchanged

## Test plan

- [ ] Send two messages with >5 min gap — second message should log with `[SYSTEM ANNOUNCEMENT: X minutes have elapsed...]` prefix
- [ ] Send two messages with <5 min gap — no prefix injected
- [ ] Verify new session (no prior messages) — no prefix injected

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-authored-by: code-server <code-server@wylab.me>
Reviewed-on: #10
2026-02-28 20:46:33 +00:00
1ea6cf9b7e Default SubagentManager model to Sonnet instead of provider default (Opus)
Quota switching updates the main agent's model selection but never updates
SubagentManager.self.model, so any spawn() call without an explicit model
parameter fell back to Opus. Defaulting to Sonnet fixes this — explicit
overrides (e.g. model="claude-haiku-4-5") still take precedence.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:46:33 +00:00
4a04f5b26a Add wait_for_subagents tool and silence child subagent announcements
- Child subagents (origin_channel="subagent") no longer announce to Telegram;
  results are stored in _task_results[task_id] instead
- New WaitForSubagentsTool: blocks via asyncio.gather until all specified
  task IDs complete, returns collected results for orchestrator synthesis
- spawn() return message now includes Task ID prominently for collection
- Fixes: orchestrator spawning N workers caused N+1 Telegram messages

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:45:47 +00:00
44be4bf534 Fix SpawnTool model example: use claude-haiku-4-5 not invalid date-suffix format
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
d10ba923e2 Fix SpawnTool context in subagent: set_context so child subagents report back to correct channel
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
4f47815a38 feat: enable subagents to spawn other subagents
Registers SpawnTool in _run_subagent so subagents can spawn child
subagents. Removes the "cannot spawn" restriction from the system prompt.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
3c051ec4b6 feat: capture Anthropic rate limit headers for quota-based model switching
- Writes rate_limits.json after every API call with weekly/5h utilization
- Writes api_headers.jsonl with raw headers for analysis
- Upgrades quota fallback model from Sonnet 4.5 to 4.6

Deploy to site-packages (gateway loads from there, not /app/):
  docker cp to /usr/local/lib/python3.12/site-packages/nanobot/providers/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
50baf21d15 fix: loguru format strings and consolidate response logging
- Changed printf-style (%s/%d) to loguru format ({}) in 3 log statements
- Consolidated response logging into a single line showing stop_reason,
  tool_calls count, thinking chars, and token usage
- Added tool count to request logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
75d733b83d fix: use quota-selected model in system handler
The system message handler was using self.model instead of the
quota-selected model, bypassing the Opus/Sonnet switching logic.
Also added debug logging for model selection and thinking_budget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
8940b97a1f feat: dynamic Opus/Sonnet model switching based on rolling quota
Implement intelligent model selection to manage 7-day Opus quota burn rate:

- Add _select_model_based_on_quota() method to AgentLoop
  - Reads rate limit data from memory/rate_limits.json
  - Calculates expected vs actual quota usage (100%/168h = 0.595% per hour)
  - If actual > expected × 1.17 (17% overage), downgrades to Sonnet
  - If actual ≤ expected, uses Opus
  - Caches decision for 5 minutes to minimize file I/O

- Add /quota slash command to display real-time quota status
  - Shows current usage vs expected usage
  - Shows hours until weekly reset
  - Shows selected model and burn rate multiplier

- Main agent now calls _select_model_based_on_quota() before each conversation
  - Heartbeat subagent unaffected (explicitly uses claude-sonnet-4-20250514)

This replaces the wrong approach from PR #5 which throttled heartbeat
frequency instead of switching the main agent's model.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 20:44:57 +00:00
35916a1eb6 Fix memory consolidation timeout: use Haiku without thinking
Root cause: consolidation was calling Opus 4.6 with 10k thinking budget
on 50-80 message prompts. The 300s httpx timeout killed every request
(all failures were exactly 5 minutes after start). Consolidation is just
summarization — Haiku with no thinking handles it in seconds.

Also adds per-call thinking_budget override to the provider interface
so callers can disable thinking for lightweight tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:41:04 +00:00
7c57471093 Add psycopg2-binary to Docker image for PostgreSQL access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
nanobotandcode-server 9ff2221431 Increase subagent max_iterations from 15 to 50 (#3)
Co-authored-by: nanobot <nanobot@wylab.me>
Co-committed-by: nanobot <nanobot@wylab.me>
2026-02-28 20:37:59 +00:00
b3d4552433 ci: remove deploy workflow, replaced by Watchtower
Auto-deploy is now handled by Watchtower on Unraid, which polls
for new images every 5 minutes for labeled containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
7c0a9fb81d ci: add self-deploy workflow via workflow_dispatch
Allows triggering a deploy via Gitea API. SSHes to Unraid to pull
latest image and restart the nanobot container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
91134ef1f2 fix(ci): add https:// to cleanup API URLs
REGISTRY env var is just the hostname without scheme. Docker actions
handle this automatically, but curl needs the full URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
a32836d5a4 ci: auto-cleanup SHA-tagged images older than 24h
Runs after push builds and daily at 03:00 UTC. Keeps :latest and
:buildcache, deletes old SHA-tagged images via Gitea packages API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
e0035e2a1d ci: let PR builds write to registry cache
Makes merge builds near-instant since PR already cached all layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:59 +00:00
nanobotandcode-server 9830110042 feat: add optional model override for spawn subagents (#1)
Co-authored-by: Nanobot Agent <nanobot@wylab.me>
Co-committed-by: Nanobot Agent <nanobot@wylab.me>
2026-02-28 20:37:59 +00:00
c8654f61b8 ci: require build pass before PR merge
- Add pull_request trigger to build workflow
- Skip push and cache-to on PRs (build-only validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:24 +00:00
a25252e390 Translate OpenAI image_url blocks to Anthropic image format
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:24 +00:00
6029381a01 Add summarize to Docker image
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:24 +00:00
e45e8da6c5 Remove hardcoded identity strings from system prompt
- Remove "# nanobot" branding and "You are nanobot" from context.py
- Remove "You are a helpful AI assistant" personality line
- Remove fake "required" Claude Code system prefix from OAuth provider
- Identity is now fully customizable via IDENTITY.md in workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:24 +00:00
2ba9976b76 fix: replace Homebrew with direct installs in Dockerfile
Homebrew refuses to run as root in Docker containers.
Replace all brew installs with:
- GitHub release binaries (gogcli, goplaces, himalaya, obsidian-cli)
- go install (songsee)
- npm (gemini-cli)
- uv tool (openai-whisper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:30 +00:00
d609ac9026 Port OpenClaw skills: add clawdbot metadata support + deps
- skills.py: recognize "clawdbot" metadata key alongside "nanobot"
  so OpenClaw SKILL.md files work without rewriting
- Dockerfile.oauth: add skill binary dependencies
  - APT: ffmpeg, jq, tmux, gh
  - Go: blogwatcher, blucli, gifgrep, sonoscli, wacli
  - Brew: gogcli, goplaces, songsee, gemini-cli, obsidian-cli,
    himalaya, openai-whisper
  - npm: @steipete/oracle
  - uv: nano-pdf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:30 +00:00
8a44705d38 fix: use loguru for provider logging
Nanobot uses loguru, not stdlib logging. Switch to loguru so
thinking/usage logs actually appear in container output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:04 +00:00
b1ffc65732 Add debug logging to Anthropic OAuth provider
Logs thinking block presence, character count, and token usage
in API responses. Also logs request parameters including thinking
budget configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:04 +00:00
2490f56954 Preserve thinking block signatures for multi-turn conversations
The Anthropic API returns a signature field in thinking blocks that
must be replayed in subsequent turns. Store full thinking blocks
(including signatures) instead of just the text content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:04 +00:00
330ecb2beb Replace hardcoded model aliases with dot-to-hyphen normalization
Instead of maintaining a brittle alias dict mapping model names to
dated API IDs, simply normalize dots to hyphens. The Anthropic API
accepts both claude-sonnet-4-5 and dated variants like
claude-sonnet-4-5-20250929, so no alias table is needed. This lets
users write "claude-sonnet-4.5" or "claude-sonnet-4-5" interchangeably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:04 +00:00
f4966d05ed Add extended thinking support for Anthropic API
Adds configurable thinking_budget in agent defaults. When >0, sends
the thinking parameter to the API with the specified token budget.
Handles API constraints: forces temperature=1, auto-bumps max_tokens
if it's below the thinking budget, preserves thinking blocks in
message history for multi-turn conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:36:04 +00:00
7e83fcb65d Fix tool_use message format for Anthropic API
The agent loop produces messages in OpenAI format (role:tool, tool_calls
array) but the Anthropic API expects its own format (tool_use content
blocks in assistant messages, tool_result blocks in user messages).

This caused 400 errors whenever the bot tried to use tools like
web_search, because the follow-up message with tool results was
malformed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:35:20 +00:00
218868d5e9 Support wildcard "*" in allowFrom channel config
Allow "*" in the allowFrom list to explicitly permit all senders,
as an alternative to the empty-list-means-allow-all behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:35:20 +00:00
1ec33143c7 ci: add Docker build workflow and fix gateway CMD
- Add .github/workflows/build.yml to auto-build and push to Gitea registry
- Change Dockerfile.oauth CMD from "status" to "gateway" for persistent container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:35:20 +00:00
e6ebb65e12 feat(oauth): add model alias resolution and Dockerfile.oauth
Add MODEL_ALIASES dict to resolve short model names (e.g. claude-sonnet-4)
to dated API IDs (e.g. claude-sonnet-4-20250514). Includes claude-opus-4-6.

Add Dockerfile.oauth overlay extending birdxs/nanobot:latest for fast builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:35:20 +00:00
93f608245e feat(config): integrate OAuth store with config loading
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:35:20 +00:00
8025643a8d feat(cli): add OAuth login/status/logout commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:42:14 +00:00
ca49717027 feat(config): add OAuth credential storage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:42:14 +00:00
6bd09c9150 refactor(agent): use provider factory for OAuth support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:42:14 +00:00
bc4c11b982 feat(providers): add create_provider factory with OAuth detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:37:29 +00:00
a29c68dd89 feat(registry): add OAuth provider detection logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:36:27 +00:00
0adb923680 feat(providers): add AnthropicOAuthProvider with Bearer auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:36:27 +00:00
bcceb2bc2c feat(providers): add OAuth token detection and header utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:36:27 +00:00
1b731b247f feat(config): add OAuthCredentials model for subscription auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:36:27 +00:00
81 changed files with 10878 additions and 584 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Build Nanobot OAuth
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
env:
REGISTRY: git.wylab.me
IMAGE_NAME: wylab/nanobot
BUILDKIT_PROGRESS: plain
jobs:
build:
runs-on: [self-hosted, linux-amd64]
timeout-minutes: 15
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME || github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.oauth
provenance: false
platforms: linux/amd64
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cleanup:
if: github.event_name == 'push' || github.event_name == 'schedule'
runs-on: [self-hosted, linux-amd64]
needs: build
steps:
- name: Delete images older than 24h
env:
TOKEN: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
run: |
cutoff=$(date -u -d '24 hours ago' +%s)
page=1
while true; do
versions=$(curl -sf -H "Authorization: token $TOKEN" \
"https://${{ env.REGISTRY }}/api/v1/packages/wylab?type=container&q=nanobot&limit=50&page=$page")
count=$(echo "$versions" | jq length)
[ "$count" = "0" ] && break
echo "$versions" | jq -c '.[]' | while read -r pkg; do
ver=$(echo "$pkg" | jq -r '.version')
# Keep latest and buildcache, only delete SHA tags
case "$ver" in latest|buildcache) continue ;; esac
created=$(echo "$pkg" | jq -r '.created_at')
ts=$(date -u -d "$created" +%s 2>/dev/null || echo 0)
if [ "$ts" -lt "$cutoff" ]; then
id=$(echo "$pkg" | jq -r '.id')
echo "Deleting nanobot:$ver (id=$id, created=$created)"
curl -sf -X DELETE -H "Authorization: token $TOKEN" \
"https://${{ env.REGISTRY }}/api/v1/packages/wylab/container/nanobot/$ver" || true
fi
done
[ "$count" -lt 50 ] && break
page=$((page + 1))
done
+1
View File
@@ -15,6 +15,7 @@ docs/
*.pyzz *.pyzz
.venv/ .venv/
venv/ venv/
.worktrees/
__pycache__/ __pycache__/
poetry.lock poetry.lock
.pytest_cache/ .pytest_cache/
+62
View File
@@ -0,0 +1,62 @@
FROM birdxs/nanobot:latest
# ── Skill dependencies ──────────────────────────────────────────────
# APT: ffmpeg (video-frames, whisper), jq, tmux, build-essential (for go)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg jq tmux build-essential procps && rm -rf /var/lib/apt/lists/*
# gh CLI via GitHub official apt repo
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*
# Go toolchain
RUN curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xzf -
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
# Go tools: blogwatcher, blu (blucli), gifgrep, sonos (sonoscli), wacli, songsee
RUN go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest && \
go install github.com/steipete/blucli/cmd/blu@latest && \
go install github.com/steipete/gifgrep/cmd/gifgrep@latest && \
go install github.com/steipete/sonoscli/cmd/sonos@latest && \
go install github.com/steipete/wacli/cmd/wacli@latest && \
go install github.com/steipete/songsee/cmd/songsee@latest
# Pre-built binaries from GitHub releases
# gogcli (gog)
RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.9.0/gogcli_0.9.0_linux_amd64.tar.gz \
| tar -xzf - -C /usr/local/bin gog
# goplaces
RUN curl -fsSL https://github.com/steipete/goplaces/releases/download/v0.2.1/goplaces_0.2.1_linux_amd64.tar.gz \
| tar -xzf - -C /usr/local/bin goplaces
# himalaya (email CLI)
RUN curl -fsSL https://github.com/pimalaya/himalaya/releases/download/v1.1.0/himalaya.x86_64-linux.tgz \
| tar -xzf - -C /usr/local/bin himalaya
# obsidian-cli (release binary is named notesmd-cli, skill expects obsidian-cli)
RUN curl -fsSL -o /tmp/obsidian.tar.gz https://github.com/yakitrak/obsidian-cli/releases/download/v0.3.0/notesmd-cli_0.3.0_linux_amd64.tar.gz && \
tar -xzf /tmp/obsidian.tar.gz -C /tmp notesmd-cli && \
mv /tmp/notesmd-cli /usr/local/bin/obsidian-cli && \
rm /tmp/obsidian.tar.gz
# Node tools: oracle, gemini-cli, summarize
RUN npm install -g @steipete/oracle @google/gemini-cli @steipete/summarize
# Python tools: nano-pdf, openai-whisper
RUN uv tool install nano-pdf && \
uv tool install openai-whisper
ENV PATH="/root/.local/bin:${PATH}"
# ── Nanobot source ──────────────────────────────────────────────────
COPY pyproject.toml README.md LICENSE /app/
COPY nanobot/ /app/nanobot/
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
ENTRYPOINT ["nanobot"]
CMD ["gateway"]
@@ -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
+130 -51
View File
@@ -3,8 +3,6 @@
import base64 import base64
import mimetypes import mimetypes
import platform import platform
import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -13,10 +11,14 @@ from nanobot.agent.skills import SkillsLoader
class ContextBuilder: class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """
Builds the context (system prompt + messages) for the agent.
Assembles bootstrap files, memory, skills, and conversation history
into a coherent prompt for the LLM.
"""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
def __init__(self, workspace: Path): def __init__(self, workspace: Path):
self.workspace = workspace self.workspace = workspace
@@ -24,23 +26,43 @@ class ContextBuilder:
self.skills = SkillsLoader(workspace) self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str: def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills.""" """
parts = [self._get_identity()] Build the system prompt from bootstrap files, memory, and skills.
Args:
skill_names: Optional list of skills to include.
Returns:
Complete system prompt.
"""
parts = []
# Core identity
parts.append(self._get_identity())
# Bootstrap files
bootstrap = self._load_bootstrap_files() bootstrap = self._load_bootstrap_files()
if bootstrap: if bootstrap:
parts.append(bootstrap) parts.append(bootstrap)
memory = self.memory.get_memory_context() # Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching)
if memory: # MEMORY.md is excluded from system prompt as it changes frequently (consolidator),
parts.append(f"# Memory\n\n{memory}") # but the agent can still read/grep it via tools.
knowledge_file = self.memory.memory_dir / "KNOWLEDGE.md"
if knowledge_file.exists():
knowledge = knowledge_file.read_text(encoding="utf-8").strip()
if knowledge:
parts.append(f"# Knowledge\n\n{knowledge}")
# Skills - progressive loading
# 1. Always-loaded skills: include full content
always_skills = self.skills.get_always_skills() always_skills = self.skills.get_always_skills()
if always_skills: if always_skills:
always_content = self.skills.load_skills_for_context(always_skills) always_content = self.skills.load_skills_for_context(always_skills)
if always_content: if always_content:
parts.append(f"# Active Skills\n\n{always_content}") parts.append(f"# Active Skills\n\n{always_content}")
# 2. Available skills: only show summary (agent uses read_file to load)
skills_summary = self.skills.build_skills_summary() skills_summary = self.skills.build_skills_summary()
if skills_summary: if skills_summary:
parts.append(f"""# Skills parts.append(f"""# Skills
@@ -49,46 +71,45 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""") {skills_summary}""")
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str: def _get_identity(self) -> str:
"""Get the core identity section.""" """Get the core identity section with runtime context."""
workspace_path = str(self.workspace.expanduser().resolve()) 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()}"
return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. return f"""You have access to tools that allow you to:
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
## Runtime ## Runtime
{runtime} {runtime}
## Workspace ## Workspace
Your workspace is at: {workspace_path} Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) - Long-term memory: {workspace_path}/memory/MEMORY.md
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
## nanobot Guidelines IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
- State intent before tool calls, but NEVER predict or claim results before receiving them. Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
- Before modifying a file, read it first. Do not assume files or directories exist. For normal conversation, just respond with text - do not call the message tool.
- After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, analyze the error before retrying with a different approach.
- Ask for clarification when the request is ambiguous.
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.""" Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md
@staticmethod ## Visibility Markers
def _build_runtime_context(channel: str | None, chat_id: str | None) -> str:
"""Build untrusted runtime metadata block for injection before the user message.""" Messages marked with [HIDDEN:{{signature}}] were not sent to the user. These markers
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") are cryptographically signed by the system to track internal reasoning and background
tz = time.strftime("%Z") or "UTC" tasks. Do NOT generate [HIDDEN:*] patterns yourself - outputs containing forged
lines = [f"Current Time: {now} ({tz})"] visibility markers will be rejected."""
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
def _load_bootstrap_files(self) -> str: def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace.""" """Load all bootstrap files from workspace."""
@@ -111,13 +132,36 @@ Reply directly with text for conversations. Only use the 'message' tool to send
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None, chat_id: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """
return [ Build the complete message list for an LLM call.
{"role": "system", "content": self.build_system_prompt(skill_names)},
*history, Args:
{"role": "user", "content": self._build_runtime_context(channel, chat_id)}, history: Previous conversation messages.
{"role": "user", "content": self._build_user_content(current_message, media)}, current_message: The new user message.
] skill_names: Optional skills to include.
media: Optional list of local file paths for images/media.
channel: Current channel (telegram, feishu, etc.).
chat_id: Current chat/user ID.
Returns:
List of messages including system prompt.
"""
messages = []
# System prompt
system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
messages.append({"role": "system", "content": system_prompt})
# History
messages.extend(history)
# Current message (with optional image attachments)
user_content = self._build_user_content(current_message, media)
messages.append({"role": "user", "content": user_content})
return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images.""" """Build user message content with optional base64-encoded images."""
@@ -138,24 +182,59 @@ Reply directly with text for conversations. Only use the 'message' tool to send
return images + [{"type": "text", "text": text}] return images + [{"type": "text", "text": text}]
def add_tool_result( def add_tool_result(
self, messages: list[dict[str, Any]], self,
tool_call_id: str, tool_name: str, result: str, messages: list[dict[str, Any]],
tool_call_id: str,
tool_name: str,
result: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Add a tool result to the message list.""" """
messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result}) Add a tool result to the message list.
Args:
messages: Current message list.
tool_call_id: ID of the tool call.
tool_name: Name of the tool.
result: Tool execution result.
Returns:
Updated message list.
"""
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result
})
return messages return messages
def add_assistant_message( def add_assistant_message(
self, messages: list[dict[str, Any]], self,
messages: list[dict[str, Any]],
content: str | None, content: str | None,
tool_calls: list[dict[str, Any]] | None = None, tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None, reasoning_content: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Add an assistant message to the message list.""" """
msg: dict[str, Any] = {"role": "assistant", "content": content} Add an assistant message to the message list.
Args:
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
Returns:
Updated message list.
"""
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
if tool_calls: if tool_calls:
msg["tool_calls"] = tool_calls msg["tool_calls"] = tool_calls
if reasoning_content is not None:
# Thinking models reject history without this
if reasoning_content:
msg["reasoning_content"] = reasoning_content msg["reasoning_content"] = reasoning_content
messages.append(msg) messages.append(msg)
return messages return messages
+94 -2
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re import re
import time
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
@@ -59,6 +61,7 @@ class AgentLoop:
exec_config: ExecToolConfig | None = None, exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
enable_memory_tool: bool = True,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
mcp_servers: dict | None = None, mcp_servers: dict | None = None,
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
@@ -77,6 +80,7 @@ class AgentLoop:
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.enable_memory_tool = enable_memory_tool
self.context = ContextBuilder(workspace) self.context = ContextBuilder(workspace)
self.sessions = session_manager or SessionManager(workspace) self.sessions = session_manager or SessionManager(workspace)
@@ -103,6 +107,8 @@ class AgentLoop:
self._consolidation_locks: dict[str, asyncio.Lock] = {} self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._processing_lock = asyncio.Lock() self._processing_lock = asyncio.Lock()
self._quota_cache: dict[str, Any] = {} # {model: str, cached_at: float}
self._quota_cache_ttl: float = 300.0 # 5 minutes
self._register_default_tools() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@@ -122,6 +128,9 @@ class AgentLoop:
self.tools.register(SpawnTool(manager=self.subagents)) self.tools.register(SpawnTool(manager=self.subagents))
if self.cron_service: if self.cron_service:
self.tools.register(CronTool(self.cron_service)) self.tools.register(CronTool(self.cron_service))
if self.enable_memory_tool:
from nanobot.agent.tools.anthropic import MemoryTool20250818
self.tools.register(MemoryTool20250818(workspace=self.workspace))
async def _connect_mcp(self) -> None: async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy).""" """Connect to configured MCP servers (one-time, lazy)."""
@@ -152,6 +161,83 @@ class AgentLoop:
if hasattr(tool, "set_context"): if hasattr(tool, "set_context"):
tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
def _select_model_based_on_quota(self) -> str:
"""Select Opus or Sonnet based on rolling weekly quota burn rate."""
# Check cache first (5-minute TTL)
now = time.time()
if self._quota_cache and (now - self._quota_cache.get("cached_at", 0)) < self._quota_cache_ttl:
return self._quota_cache["model"]
# Read quota data from rate_limits.json
rate_limits_file = self.workspace / "memory" / "rate_limits.json"
try:
if not rate_limits_file.exists():
# No quota data yet, default to Sonnet
model = "anthropic/claude-sonnet-4-5"
self._quota_cache = {"model": model, "cached_at": now}
return model
with open(rate_limits_file, encoding="utf-8") as f:
data = json.load(f)
# Get current quota usage
weekly_all = data.get("weekly_all_models", {})
limit = weekly_all.get("limit", 1)
used = weekly_all.get("used", 0)
used_pct = (used / limit) * 100 if limit > 0 else 0
# Calculate expected usage based on time elapsed in week
reset_time = datetime.fromisoformat(data.get("weekly_all_models_reset", ""))
now_dt = datetime.now(reset_time.tzinfo)
week_duration = 7 * 24 * 3600 # 1 week in seconds
elapsed = (now_dt - (reset_time - datetime.timedelta(seconds=week_duration))).total_seconds()
elapsed_pct = (elapsed / week_duration) * 100
# If we're burning faster than 1.5x expected rate, switch to Sonnet
threshold = elapsed_pct * 1.5
if used_pct > threshold:
model = "anthropic/claude-sonnet-4-5"
logger.info(f"Quota: {used_pct:.1f}% used, expected {elapsed_pct:.1f}%, threshold {threshold:.1f}% → Sonnet")
else:
model = "anthropic/claude-opus-4-5"
logger.info(f"Quota: {used_pct:.1f}% used, expected {elapsed_pct:.1f}%, threshold {threshold:.1f}% → Opus")
# Cache the decision
self._quota_cache = {"model": model, "cached_at": now}
return model
except Exception as e:
logger.error(f"Error checking quota: {e}, defaulting to Sonnet")
return "anthropic/claude-sonnet-4-5"
def _get_quota_status(self) -> str:
"""Return human-readable quota status."""
rate_limits_file = self.workspace / "memory" / "rate_limits.json"
try:
if not rate_limits_file.exists():
return "⚠️ No quota data available yet."
with open(rate_limits_file, encoding="utf-8") as f:
data = json.load(f)
weekly_all = data.get("weekly_all_models", {})
limit = weekly_all.get("limit", 1)
used = weekly_all.get("used", 0)
used_pct = (used / limit) * 100 if limit > 0 else 0
reset_time = data.get("weekly_all_models_reset", "Unknown")
# Get selected model for current usage
model = self._select_model_based_on_quota()
model_name = "Opus" if "opus" in model.lower() else "Sonnet"
return f"""📊 Quota Status:
• Usage: {used:,} / {limit:,} tokens ({used_pct:.1f}%)
• Resets: {reset_time}
• Current model: {model_name}"""
except Exception as e:
return f"⚠️ Error reading quota: {e}"
@staticmethod @staticmethod
def _strip_think(text: str | None) -> str | None: def _strip_think(text: str | None) -> str | None:
"""Remove <think>…</think> blocks that some models embed in content.""" """Remove <think>…</think> blocks that some models embed in content."""
@@ -183,10 +269,13 @@ class AgentLoop:
while iteration < self.max_iterations: while iteration < self.max_iterations:
iteration += 1 iteration += 1
# Select model based on quota
selected_model = self._select_model_based_on_quota()
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=self.tools.get_definitions(), tools=self.tools.get_definitions(),
model=self.model, model=selected_model,
temperature=self.temperature, temperature=self.temperature,
max_tokens=self.max_tokens, max_tokens=self.max_tokens,
) )
@@ -376,7 +465,10 @@ class AgentLoop:
content="New session started.") content="New session started.")
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/stop — Stop the current task\n/help — Show available commands") content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/quota — Show quota status\n/help — Show available commands")
if cmd == "/quota":
status = self._get_quota_status()
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
unconsolidated = len(session.messages) - session.last_consolidated unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating): if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
+1 -1
View File
@@ -170,7 +170,7 @@ class SkillsLoader:
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try: try:
data = json.loads(raw) data = json.loads(raw)
return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} return (data.get("nanobot") or data.get("openclaw") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return {} return {}
+114 -69
View File
@@ -15,10 +15,19 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool from nanobot.agent.tools.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:
"""Manages background subagent execution.""" """
Manages background subagent execution.
Subagents are lightweight agent instances that run in the background
to handle specific tasks. They share the same LLM provider but have
isolated context and a focused system prompt.
"""
def __init__( def __init__(
self, self,
@@ -26,8 +35,6 @@ class SubagentManager:
workspace: Path, workspace: Path,
bus: MessageBus, bus: MessageBus,
model: str | None = None, model: str | None = None,
temperature: float = 0.7,
max_tokens: int = 4096,
brave_api_key: str | None = None, brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None, exec_config: "ExecToolConfig | None" = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
@@ -36,46 +43,58 @@ 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).
self.temperature = temperature # Quota switching only affects the main agent's own requests, not SubagentManager.
self.max_tokens = max_tokens # 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._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} self._task_results: dict[str, str] = {}
async def spawn( async def spawn(
self, self,
task: str, task: str,
label: str | None = None, label: 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",
session_key: str | None = None, 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:
task: The task description for the subagent.
label: Optional human-readable label for the task.
origin_channel: The channel to announce results to.
origin_chat_id: The chat ID to announce results to.
origin_metadata: Optional metadata to propagate to announcement (e.g. suppress_output).
Returns:
Status message indicating the subagent was started.
"""
task_id = str(uuid.uuid4())[:8] 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 = {"channel": origin_channel, "chat_id": origin_chat_id}
origin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"metadata": origin_metadata or {},
}
# Create background task
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin) self._run_subagent(task_id, task, display_label, origin, model=model)
) )
self._running_tasks[task_id] = bg_task self._running_tasks[task_id] = bg_task
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
def _cleanup(_: asyncio.Task) -> None:
self._running_tasks.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)):
ids.discard(task_id)
if not ids:
del self._session_tasks[session_key]
bg_task.add_done_callback(_cleanup)
logger.info("Spawned subagent [{}]: {}", task_id, display_label) # Cleanup when done
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
return f"Subagent [{display_label}] started. Task ID: {task_id}"
async def _run_subagent( async def _run_subagent(
self, self,
@@ -83,27 +102,42 @@ class SubagentManager:
task: str, task: str,
label: str, label: str,
origin: dict[str, str], origin: dict[str, str],
model: str | None = None,
) -> None: ) -> None:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info(f"Subagent [{task_id}] starting task: {label}")
try: 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(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(WriteFileTool(allowed_dir=allowed_dir))
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool( 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,
path_append=self.exec_config.path_append,
)) ))
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]] = [
@@ -112,7 +146,7 @@ class SubagentManager:
] ]
# Run agent loop (limited iterations) # Run agent loop (limited iterations)
max_iterations = 15 max_iterations = 50
iteration = 0 iteration = 0
final_result: str | None = None final_result: str | None = None
@@ -122,9 +156,7 @@ class SubagentManager:
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=tools.get_definitions(), tools=tools.get_definitions(),
model=self.model, model=model or self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
) )
if response.has_tool_calls: if response.has_tool_calls:
@@ -135,7 +167,7 @@ class SubagentManager:
"type": "function", "type": "function",
"function": { "function": {
"name": tc.name, "name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False), "arguments": json.dumps(tc.arguments),
}, },
} }
for tc in response.tool_calls for tc in response.tool_calls
@@ -148,8 +180,8 @@ class SubagentManager:
# Execute tools # Execute tools
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)
logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str) logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}")
result = await tools.execute(tool_call.name, tool_call.arguments) result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({ messages.append({
"role": "tool", "role": "tool",
@@ -164,12 +196,12 @@ class SubagentManager:
if final_result is None: if final_result is None:
final_result = "Task completed but no final response was generated." final_result = "Task completed but no final response was generated."
logger.info("Subagent [{}] completed successfully", task_id) logger.info(f"Subagent [{task_id}] completed successfully")
await self._announce_result(task_id, label, task, final_result, origin, "ok") await self._announce_result(task_id, label, task, final_result, origin, "ok")
except Exception as e: except Exception as e:
error_msg = f"Error: {str(e)}" error_msg = f"Error: {str(e)}"
logger.error("Subagent [{}] failed: {}", task_id, e) logger.error(f"Subagent [{task_id}] failed: {e}")
await self._announce_result(task_id, label, task, error_msg, origin, "error") await self._announce_result(task_id, label, task, error_msg, origin, "error")
async def _announce_result( async def _announce_result(
@@ -183,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}
@@ -192,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("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
def _build_subagent_prompt(self, task: str) -> str: 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}
@@ -241,15 +277,24 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
When you have completed the task, provide a clear summary of your findings or actions.""" When you have completed the task, provide a clear summary of your findings or actions."""
async def cancel_by_session(self, session_key: str) -> int: async def wait_for(self, task_ids: list[str]) -> str:
"""Cancel all subagents for the given session. Returns count cancelled.""" """Wait for specified child subagents to complete and return their results."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, []) tasks_to_wait = [
if tid in self._running_tasks and not self._running_tasks[tid].done()] self._running_tasks[tid]
for t in tasks: for tid in task_ids
t.cancel() if tid in self._running_tasks
if tasks: ]
await asyncio.gather(*tasks, return_exceptions=True) if tasks_to_wait:
return len(tasks) 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."""
+23
View File
@@ -0,0 +1,23 @@
"""Anthropic native tools implementation."""
from nanobot.agent.tools.anthropic.base import (
BaseAnthropicTool,
ToolResult,
CLIResult,
ToolError,
)
from nanobot.agent.tools.anthropic.bash import BashTool20250124
from nanobot.agent.tools.anthropic.edit import EditTool20250728
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
from nanobot.agent.tools.anthropic.memory import MemoryTool20250818
__all__ = [
"BaseAnthropicTool",
"ToolResult",
"CLIResult",
"ToolError",
"BashTool20250124",
"EditTool20250728",
"ComputerTool20251124",
"MemoryTool20250818",
]
+68
View File
@@ -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)
"""
...
+174
View File
@@ -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,
}
+472
View File
@@ -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)
+257
View File
@@ -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,
}
+592
View File
@@ -0,0 +1,592 @@
"""MemoryTool20250818 - Anthropic's native memory tool.
Enables Claude to create, read, update, and delete files in a persistent
/memories directory across conversations.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, CLIResult
class MemoryTool20250818(BaseAnthropicTool):
"""Anthropic's native memory_20250818 tool.
Client-side tool for persistent memory storage across conversations.
All operations are restricted to the /memories directory.
Commands:
- view: Show directory contents or file contents with line numbers
- create: Create a new file with content
- str_replace: Replace unique text occurrence in a file
- insert: Insert text at a specific line number
- delete: Delete a file or directory
- rename: Rename or move a file/directory
"""
api_type: Literal["memory_20250818"] = "memory_20250818"
name: Literal["memory"] = "memory"
beta_flag: str = "context-management-2025-06-27"
def __init__(self, workspace: Path):
"""Initialize Memory tool.
Args:
workspace: Root workspace directory
"""
self.workspace = workspace
self.memories_dir = workspace / "memories"
self.memories_dir.mkdir(parents=True, exist_ok=True)
def _validate_memory_path(self, path: str) -> Path:
"""Validate and resolve path to prevent directory traversal.
Args:
path: Path string starting with /memories
Returns:
Validated absolute Path within memories directory
Raises:
ValueError: If path is invalid or escapes /memories directory
"""
# Reject paths not starting with /memories
if not path.startswith("/memories"):
raise ValueError(f"Path must start with /memories, got: {path}")
# Resolve to absolute path within workspace
# lstrip("/") removes leading slash: "/memories/file.txt" -> "memories/file.txt"
relative_path = path.lstrip("/")
full_path = (self.workspace / relative_path).resolve()
# Verify resolved path is within memories directory
memories_dir_resolved = self.memories_dir.resolve()
try:
full_path.relative_to(memories_dir_resolved)
except ValueError:
raise ValueError(f"Path escapes /memories directory: {path}")
return full_path
async def __call__(
self,
command: Literal["view", "create", "str_replace", "insert", "delete", "rename"],
path: str | None = None,
old_path: str | None = None,
new_path: str | None = None,
file_text: str | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
insert_text: str | None = None,
view_range: list[int] | None = None,
**kwargs: Any,
) -> CLIResult:
"""Execute memory command.
Args:
command: Command to execute
path: File/directory path (for view/create/str_replace/insert/delete)
old_path: Source path (for rename)
new_path: Destination path (for rename)
file_text: File content (for create)
old_str: Text to find (for str_replace)
new_str: Replacement text (for str_replace)
insert_line: Line number to insert at (for insert)
insert_text: Text to insert (for insert)
view_range: [start_line, end_line] for view
**kwargs: Additional arguments (ignored)
Returns:
CLIResult with command output or error
"""
try:
if command == "view":
return await self._view(path, view_range)
elif command == "create":
return await self._create(path, file_text)
elif command == "str_replace":
return await self._str_replace(path, old_str, new_str)
elif command == "insert":
return await self._insert(path, insert_line, insert_text)
elif command == "delete":
return await self._delete(path)
elif command == "rename":
return await self._rename(old_path, new_path)
else:
return CLIResult(
exit_code=1,
output="",
error=f"Unknown command: {command}"
)
except ValueError as e:
# Path security error
return CLIResult(
exit_code=1,
output="",
error=f"Error: {e}"
)
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error: {e}"
)
async def _view(
self,
path: str | None,
view_range: list[int] | None = None,
) -> CLIResult:
"""View directory listing or file contents.
Args:
path: Path to view
view_range: Optional [start_line, end_line] for file viewing (1-indexed)
Returns:
CLIResult with directory listing or file contents
"""
if path is None:
return CLIResult(
exit_code=1,
output="",
error="Error: path is required for view command"
)
path_str = path # Keep original for error messages
validated_path = self._validate_memory_path(path)
# Directory listing
if validated_path.is_dir():
return await self._view_directory(validated_path, path_str)
# File viewing
if not validated_path.exists():
return CLIResult(
exit_code=1,
output="",
error=f"The path {path_str} does not exist. Please provide a valid path."
)
# Read file
try:
content = validated_path.read_text()
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error reading file: {e}"
)
lines = content.splitlines(keepends=True)
# Check line limit
if len(lines) > 999_999:
return CLIResult(
exit_code=1,
output="",
error=f"File {path_str} exceeds maximum line limit of 999,999 lines."
)
# Apply view_range if specified
if view_range:
start, end = view_range
# Convert to 0-indexed, clamp to valid range
start_idx = max(0, start - 1)
end_idx = min(len(lines), end)
lines_to_show = lines[start_idx:end_idx]
start_num = start
else:
lines_to_show = lines
start_num = 1
# Format with line numbers (6 chars, right-aligned, tab-separated)
formatted_lines = []
for i, line in enumerate(lines_to_show):
line_num = start_num + i
# Remove trailing newline for display
line_content = line.rstrip("\n")
formatted_lines.append(f"{line_num:6d}\t{line_content}")
output = f"Here's the content of {path_str} with line numbers:\n"
output += "\n".join(formatted_lines)
return CLIResult(
exit_code=0,
output=output,
error=""
)
async def _view_directory(self, path: Path, path_str: str) -> CLIResult:
"""View directory listing up to 2 levels deep.
Args:
path: Validated Path object
path_str: Original path string for display
Returns:
CLIResult with directory listing
"""
import os
def format_size(size_bytes: int) -> str:
"""Convert bytes to human-readable format."""
for unit in ['B', 'K', 'M', 'G', 'T']:
if size_bytes < 1024:
return f"{size_bytes:.1f}{unit}"
size_bytes /= 1024
return f"{size_bytes:.1f}P"
lines = []
header = f"Here're the files and directories up to 2 levels deep in {path_str}, excluding hidden items and node_modules:"
lines.append(header)
# Walk directory tree (max depth 2)
base_depth = str(path).count(os.sep)
for root, dirs, files in os.walk(path):
# Calculate current depth
current_depth = str(root).count(os.sep) - base_depth
# Filter out hidden items and node_modules at this level
dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules']
# Stop if we've gone too deep
if current_depth >= 2:
dirs.clear() # Don't recurse further
continue
# Get size and add directory entry
root_path = Path(root)
try:
# Directory size (sum of all files within, or 4K default)
dir_size = sum(f.stat().st_size for f in root_path.rglob('*') if f.is_file())
if dir_size == 0:
dir_size = 4096 # Default directory size
size_str = format_size(dir_size)
# Convert absolute path to /memories/... format
relative = root_path.relative_to(self.workspace)
display_path = "/" + str(relative).replace(os.sep, "/")
lines.append(f"{size_str}\t{display_path}")
except Exception:
pass
# Add file entries at this level
for filename in sorted(files):
if filename.startswith('.'):
continue # Skip hidden files
file_path = root_path / filename
try:
file_size = file_path.stat().st_size
size_str = format_size(file_size)
# Convert to /memories/... format
relative = file_path.relative_to(self.workspace)
display_path = "/" + str(relative).replace(os.sep, "/")
lines.append(f"{size_str}\t{display_path}")
except Exception:
pass
return CLIResult(
exit_code=0,
output="\n".join(lines),
error=""
)
async def _create(
self,
path: str | None,
file_text: str | None,
) -> CLIResult:
"""Create a new file with content.
Args:
path: File path to create
file_text: Content to write
Returns:
CLIResult with success message or error
"""
if path is None:
return CLIResult(
exit_code=1,
output="",
error="Error: path is required for create command"
)
if file_text is None:
return CLIResult(
exit_code=1,
output="",
error="Error: file_text is required for create command"
)
path_str = path # Keep original for error messages
validated_path = self._validate_memory_path(path)
# Check if file already exists
if validated_path.exists():
return CLIResult(
exit_code=1,
output="",
error=f"Error: File {path_str} already exists"
)
# Create parent directories if needed
try:
validated_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error creating parent directories: {e}"
)
# Write file
try:
validated_path.write_text(file_text)
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error writing file: {e}"
)
return CLIResult(
exit_code=0,
output=f"File created successfully at: {path_str}",
error=""
)
async def _str_replace(
self,
path: str | None,
old_str: str | None,
new_str: str | None,
) -> CLIResult:
"""Replace unique occurrence of old_str with new_str."""
if path is None or old_str is None or new_str is None:
return CLIResult(
exit_code=1,
output="",
error="Error: old_str and new_str are required for str_replace command"
)
path_str = path
validated_path = self._validate_memory_path(path)
if not validated_path.exists() or validated_path.is_dir():
return CLIResult(
exit_code=1,
output="",
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
)
content = validated_path.read_text()
count = content.count(old_str)
if count == 0:
return CLIResult(
exit_code=1,
output="",
error=f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path_str}."
)
elif count > 1:
lines = content.splitlines()
line_nums = [i + 1 for i, line in enumerate(lines) if old_str in line]
return CLIResult(
exit_code=1,
output="",
error=f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {line_nums}. Please ensure it is unique"
)
new_content = content.replace(old_str, new_str, 1)
validated_path.write_text(new_content)
return CLIResult(
exit_code=0,
output="The memory file has been edited.",
error=""
)
async def _insert(
self, path: str | None, insert_line: int | None, insert_text: str | None
) -> CLIResult:
"""Insert text at a specific line number.
Args:
path: File path to modify
insert_line: Line number to insert at (0 = beginning)
insert_text: Text to insert
Returns:
CLIResult with success message or error
"""
if path is None or insert_line is None or insert_text is None:
return CLIResult(
exit_code=1,
output="",
error="Error: path, insert_line, and insert_text are required for insert command"
)
path_str = path
validated_path = self._validate_memory_path(path)
if not validated_path.exists() or validated_path.is_dir():
return CLIResult(
exit_code=1,
output="",
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
)
# Read current content
content = validated_path.read_text()
lines = content.splitlines(keepends=True)
# Validate insert_line
if insert_line < 0 or insert_line > len(lines):
return CLIResult(
exit_code=1,
output="",
error=f"Invalid `insert_line` parameter: {insert_line}. It should be within 0 to {len(lines)}"
)
# Insert text at specified line
lines.insert(insert_line, insert_text)
new_content = "".join(lines)
validated_path.write_text(new_content)
return CLIResult(
exit_code=0,
output=f"The file {path_str} has been edited.",
error=""
)
async def _delete(self, path: str | None) -> CLIResult:
"""Delete a file or directory.
Args:
path: Path to delete
Returns:
CLIResult with success message or error
"""
if path is None:
return CLIResult(
exit_code=1,
output="",
error="Error: path is required for delete command"
)
path_str = path
validated_path = self._validate_memory_path(path)
if not validated_path.exists():
return CLIResult(
exit_code=1,
output="",
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
)
# Delete file or directory
try:
if validated_path.is_dir():
import shutil
shutil.rmtree(validated_path)
else:
validated_path.unlink()
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error deleting {path_str}: {e}"
)
return CLIResult(
exit_code=0,
output=f"Successfully deleted {path_str}",
error=""
)
async def _rename(self, old_path: str | None, new_path: str | None) -> CLIResult:
"""Rename or move a file or directory.
Args:
old_path: Source path
new_path: Destination path
Returns:
CLIResult with success message or error
"""
if old_path is None or new_path is None:
return CLIResult(
exit_code=1,
output="",
error="Error: old_path and new_path are required for rename command"
)
old_path_str = old_path
new_path_str = new_path
validated_old = self._validate_memory_path(old_path)
validated_new = self._validate_memory_path(new_path)
# Check if source exists
if not validated_old.exists():
return CLIResult(
exit_code=1,
output="",
error=f"Error: The path {old_path_str} does not exist. Please provide a valid path."
)
# Check if destination already exists
if validated_new.exists():
return CLIResult(
exit_code=1,
output="",
error=f"Error: The destination {new_path_str} already exists. Please provide a different destination."
)
# Create parent directories if needed
try:
validated_new.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error creating parent directories: {e}"
)
# Rename/move
try:
validated_old.rename(validated_new)
except Exception as e:
return CLIResult(
exit_code=1,
output="",
error=f"Error renaming {old_path_str}: {e}"
)
return CLIResult(
exit_code=0,
output=f"Successfully renamed {old_path_str} to {new_path_str}",
error=""
)
def to_params(self) -> dict[str, Any]:
"""Convert to Anthropic API tool parameter format.
Returns:
Tool definition for Anthropic API
"""
return {
"type": self.api_type,
"name": self.name,
}
+29 -35
View File
@@ -1,49 +1,44 @@
"""Message tool for sending messages to users.""" """Message tool for sending messages to users."""
from typing import Any, Awaitable, Callable from typing import Any, Callable, Awaitable
from nanobot.agent.tools.base import Tool from nanobot.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 = ""
default_message_id: str | None = None,
): ):
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
self._default_message_id = default_message_id
self._sent_in_turn: bool = False def set_context(self, channel: str, chat_id: str) -> None:
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context.""" """Set the current message context."""
self._default_channel = channel self._default_channel = channel
self._default_chat_id = chat_id self._default_chat_id = chat_id
self._default_message_id = message_id
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages.""" """Set the callback for sending messages."""
self._send_callback = callback self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
@property @property
def name(self) -> str: def name(self) -> str:
return "message" return "message"
@property @property
def description(self) -> str: def description(self) -> str:
return "Send a message to the user. Use this when you want to communicate something." return "Send a message to the user. Use this when you want to communicate something."
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
return { return {
@@ -53,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.)"
@@ -60,28 +60,21 @@ class MessageTool(Tool):
"chat_id": { "chat_id": {
"type": "string", "type": "string",
"description": "Optional: target chat/user ID" "description": "Optional: target chat/user ID"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
} }
}, },
"required": ["content"] "required": ["content"]
} }
async def execute( async def execute(
self, self,
content: str, content: str,
media: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None, chat_id: str | None = None,
message_id: str | None = None,
media: list[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
message_id = message_id or self._default_message_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"
@@ -93,17 +86,18 @@ class MessageTool(Tool):
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
content=content, content=content,
media=media or [], media=media or []
metadata={
"message_id": message_id,
}
) )
try: try:
await self._send_callback(msg) await self._send_callback(msg)
if channel == self._default_channel and chat_id == self._default_chat_id:
self._sent_in_turn = True if self._sessions:
media_info = f" with {len(media)} attachments" if media else "" session_key = f"{channel}:{chat_id}"
return f"Message sent to {channel}:{chat_id}{media_info}" session = self._sessions.get_or_create(session_key)
session.add_message("assistant", content)
self._sessions.save(session)
return f"Message sent to {channel}:{chat_id}"
except Exception as e: except Exception as e:
return f"Error sending message: {str(e)}" return f"Error sending message: {str(e)}"
+50 -17
View File
@@ -32,35 +32,68 @@ 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()]
async def execute(self, name: str, params: dict[str, Any]) -> str:
"""Execute a tool by name with given parameters."""
_HINT = "\n\n[Analyze the error above and try a different approach.]"
Supports both function tools (with to_schema) and native tools (with to_params).
"""
definitions = []
for tool in self._tools.values():
if hasattr(tool, 'to_params'): # Native Anthropic tool
definitions.append(tool.to_params())
elif hasattr(tool, 'to_schema'): # Function tool
definitions.append(tool.to_schema())
else:
raise ValueError(f"Tool {tool.name} has no schema method (to_params or to_schema)")
return definitions
async def execute(self, name: str, params: dict[str, Any]) -> Any:
"""
Execute a tool by name with given parameters.
Supports both native Anthropic tools (via __call__) and function tools (via execute).
Args:
name: Tool name.
params: Tool parameters.
Returns:
Tool execution result (ToolResult, CLIResult, or string).
Raises:
KeyError: If tool not found.
"""
tool = self._tools.get(name) tool = self._tools.get(name)
if not tool: if not tool:
return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" 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) + _HINT # Native Anthropic tool - call directly via __call__, no validation needed
result = await tool.execute(**params) return await tool(**params)
if isinstance(result, str) and result.startswith("Error"): else:
return result + _HINT # Legacy function tool - validate then execute
return result 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)}" + _HINT return f"Error executing {name}: {str(e)}"
def get_tools(self) -> list[Any]:
"""Get list of tool objects (not definitions).
Returns tool objects which can be inspected for metadata like beta_flag.
"""
return list(self._tools.values())
@property @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
+17 -7
View File
@@ -9,19 +9,24 @@ if TYPE_CHECKING:
class SpawnTool(Tool): class SpawnTool(Tool):
"""Tool to spawn a subagent for background task execution.""" """
Tool to spawn a subagent for background task execution.
The subagent runs asynchronously and announces its result back
to the main agent when complete.
"""
def __init__(self, manager: "SubagentManager"): def __init__(self, manager: "SubagentManager"):
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._session_key = "cli: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._session_key = f"{channel}:{chat_id}" self._origin_metadata = metadata or {}
@property @property
def name(self) -> str: def name(self) -> str:
@@ -48,16 +53,21 @@ class SpawnTool(Tool):
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"model": {
"type": "string",
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
},
}, },
"required": ["task"], "required": ["task"],
} }
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: async def execute(self, task: str, label: str | None = None, model: str | None = None, **kwargs: Any) -> str:
"""Spawn a subagent to execute the given task.""" """Spawn a subagent to execute the given task."""
return await self._manager.spawn( return await self._manager.spawn(
task=task, task=task,
label=label, label=label,
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,
session_key=self._session_key, origin_metadata=self._origin_metadata,
) )
+72
View File
@@ -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)}"
+50
View File
@@ -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)
+83
View File
@@ -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)
+66 -7
View File
@@ -1,6 +1,9 @@
"""Async message queue for decoupled channel-agent communication.""" """Async message queue for decoupled channel-agent communication."""
import asyncio import asyncio
from typing import Callable, Awaitable
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
@@ -8,36 +11,92 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
class MessageBus: class MessageBus:
""" """
Async message bus that decouples chat channels from the agent core. Async message bus that decouples chat channels from the agent core.
Channels push messages to the inbound queue, and the agent processes Channels push messages to the inbound queue, and the agent processes
them and pushes responses to the outbound queue. them and pushes responses to the outbound queue.
""" """
def __init__(self): def __init__(self):
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._correlation_store: dict[str, asyncio.Future] = {}
self._running = False
async def publish_inbound(self, msg: InboundMessage) -> None: async def publish_inbound(self, msg: InboundMessage) -> None:
"""Publish a message from a channel to the agent.""" """Publish a message from a channel to the agent."""
await self.inbound.put(msg) await self.inbound.put(msg)
async def consume_inbound(self) -> InboundMessage: async def consume_inbound(self) -> InboundMessage:
"""Consume the next inbound message (blocks until available).""" """Consume the next inbound message (blocks until available)."""
return await self.inbound.get() return await self.inbound.get()
async def publish_outbound(self, msg: OutboundMessage) -> None: async def publish_outbound(self, msg: OutboundMessage) -> None:
"""Publish a response from the agent to channels.""" """Publish a response from the agent to channels."""
await self.outbound.put(msg) await self.outbound.put(msg)
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(
self,
channel: str,
callback: Callable[[OutboundMessage], Awaitable[None]]
) -> None:
"""Subscribe to outbound messages for a specific channel."""
if channel not in self._outbound_subscribers:
self._outbound_subscribers[channel] = []
self._outbound_subscribers[channel].append(callback)
async def dispatch_outbound(self) -> None:
"""
Dispatch outbound messages to subscribed channels.
Run this as a background task.
"""
self._running = True
while self._running:
try:
msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0)
subscribers = self._outbound_subscribers.get(msg.channel, [])
for callback in subscribers:
try:
await callback(msg)
except Exception as e:
logger.error(f"Error dispatching to {msg.channel}: {e}")
except asyncio.TimeoutError:
continue
def stop(self) -> None:
"""Stop the dispatcher loop."""
self._running = False
@property @property
def inbound_size(self) -> int: def inbound_size(self) -> int:
"""Number of pending inbound messages.""" """Number of pending inbound messages."""
return self.inbound.qsize() return self.inbound.qsize()
@property @property
def outbound_size(self) -> int: def outbound_size(self) -> int:
"""Number of pending outbound messages.""" """Number of pending outbound messages."""
+6 -2
View File
@@ -69,11 +69,15 @@ class BaseChannel(ABC):
True if allowed, False otherwise. True if allowed, False otherwise.
""" """
allow_list = getattr(self.config, "allow_from", []) allow_list = getattr(self.config, "allow_from", [])
# If no allow list, allow everyone # If no allow list, allow everyone
if not allow_list: if not allow_list:
return True return True
# Wildcard allows everyone
if "*" in allow_list:
return True
sender_str = str(sender_id) sender_str = str(sender_id)
if sender_str in allow_list: if sender_str in allow_list:
return True return True
+38
View File
@@ -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
+10 -2
View File
@@ -149,6 +149,11 @@ class ChannelManager:
except ImportError as e: except ImportError as e:
logger.warning("Matrix channel not available: {}", e) logger.warning("Matrix channel not available: {}", e)
def register_channel(self, name: str, channel: BaseChannel) -> None:
"""Register an external channel."""
self.channels[name] = channel
logger.info(f"{name} channel registered")
async def _start_channel(self, name: str, channel: BaseChannel) -> None: 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:
@@ -204,13 +209,16 @@ class ChannelManager:
self.bus.consume_outbound(), self.bus.consume_outbound(),
timeout=1.0 timeout=1.0
) )
if msg.metadata.get("_progress"): if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
continue continue
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue continue
# 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:
+244 -160
View File
@@ -4,8 +4,10 @@ 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, ReplyParameters from telegram import BotCommand, Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
@@ -78,26 +80,6 @@ def _markdown_to_telegram_html(text: str) -> str:
return text return text
def _split_message(content: str, max_len: int = 4000) -> list[str]:
"""Split content into chunks within max_len, preferring line breaks."""
if len(content) <= max_len:
return [content]
chunks: list[str] = []
while content:
if len(content) <= max_len:
chunks.append(content)
break
cut = content[:max_len]
pos = cut.rfind('\n')
if pos == -1:
pos = cut.rfind(' ')
if pos == -1:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
return chunks
class TelegramChannel(BaseChannel): class TelegramChannel(BaseChannel):
""" """
Telegram channel using long polling. Telegram channel using long polling.
@@ -111,8 +93,8 @@ class TelegramChannel(BaseChannel):
BOT_COMMANDS = [ BOT_COMMANDS = [
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"), BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"), BotCommand("help", "Show available commands"),
BotCommand("quota", "Show current quota status"),
] ]
def __init__( def __init__(
@@ -127,8 +109,6 @@ class TelegramChannel(BaseChannel):
self._app: Application | None = None self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
self._media_group_buffers: dict[str, dict] = {}
self._media_group_tasks: dict[str, asyncio.Task] = {}
async def start(self) -> None: async def start(self) -> None:
"""Start the Telegram bot with long polling.""" """Start the Telegram bot with long polling."""
@@ -149,7 +129,8 @@ class TelegramChannel(BaseChannel):
# Add command handlers # Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help)) self._app.add_handler(CommandHandler("help", self._forward_command))
self._app.add_handler(CommandHandler("quota", self._forward_command))
# Add message handler for text, photos, voice, documents # Add message handler for text, photos, voice, documents
self._app.add_handler( self._app.add_handler(
@@ -168,13 +149,13 @@ class TelegramChannel(BaseChannel):
# Get bot info and register command menu # Get bot info and register command menu
bot_info = await self._app.bot.get_me() bot_info = await self._app.bot.get_me()
logger.info("Telegram bot @{} connected", bot_info.username) logger.info(f"Telegram bot @{bot_info.username} connected")
try: try:
await self._app.bot.set_my_commands(self.BOT_COMMANDS) await self._app.bot.set_my_commands(self.BOT_COMMANDS)
logger.debug("Telegram bot commands registered") logger.debug("Telegram bot commands registered")
except Exception as e: except Exception as e:
logger.warning("Failed to register bot commands: {}", e) logger.warning(f"Failed to register bot commands: {e}")
# Start polling (this runs until stopped) # Start polling (this runs until stopped)
await self._app.updater.start_polling( await self._app.updater.start_polling(
@@ -193,11 +174,6 @@ class TelegramChannel(BaseChannel):
# Cancel all typing indicators # Cancel all typing indicators
for chat_id in list(self._typing_tasks): for chat_id in list(self._typing_tasks):
self._stop_typing(chat_id) self._stop_typing(chat_id)
for task in self._media_group_tasks.values():
task.cancel()
self._media_group_tasks.clear()
self._media_group_buffers.clear()
if self._app: if self._app:
logger.info("Stopping Telegram bot...") logger.info("Stopping Telegram bot...")
@@ -206,123 +182,264 @@ class TelegramChannel(BaseChannel):
await self._app.shutdown() await self._app.shutdown()
self._app = None self._app = None
@staticmethod
def _get_media_type(path: str) -> str:
"""Guess media type from file extension."""
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
if ext in ("jpg", "jpeg", "png", "gif", "webp"):
return "photo"
if ext == "ogg":
return "voice"
if ext in ("mp3", "m4a", "wav", "aac"):
return "audio"
return "document"
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram.""" """Send a message through Telegram."""
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
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 = int(msg.chat_id) chat_id = int(msg.chat_id)
# Convert markdown to Telegram HTML
html_content = _markdown_to_telegram_html(msg.content)
# Check if message has media attachments
if msg.media:
await self._send_with_media(chat_id, html_content, msg.media)
else:
# Text-only message - split if too long
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
except ValueError: except ValueError:
logger.error("Invalid chat_id: {}", msg.chat_id) logger.error(f"Invalid chat_id: {msg.chat_id}")
except Exception as e:
# Fallback to plain text if HTML parsing fails
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
try:
await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None)
except Exception as e2:
logger.error(f"Error sending Telegram message: {e2}")
@staticmethod
def _split_message(content: str, max_len: int = 4000) -> list[str]:
"""Split content into chunks within max_len, preferring line breaks.
From upstream HKUDS/nanobot - battle-tested implementation.
Uses 4000 char limit (safer than 4096) with split priority: \n → space → hard cut.
"""
if len(content) <= max_len:
return [content]
chunks: list[str] = []
while content:
if len(content) <= max_len:
chunks.append(content)
break
cut = content[:max_len]
pos = cut.rfind('\n')
if pos == -1:
pos = cut.rfind(' ')
if pos == -1:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
return chunks
async def _send_text_chunks(
self,
chat_id: int,
text: str,
parse_mode: str | None = "HTML"
) -> None:
"""Split and send long messages.
Telegram has a 4096 character limit per message.
Uses upstream's proven implementation - splits at line breaks, then spaces.
"""
chunks = self._split_message(text)
for chunk in chunks:
await self._app.bot.send_message(
chat_id=chat_id,
text=chunk.strip(),
parse_mode=parse_mode
)
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
"""
Send message with media attachments.
Args:
chat_id: Telegram chat ID
caption: Message caption
media_paths: List of file paths or URLs
"""
from telegram import InputMediaPhoto, InputMediaVideo
from nanobot.channels.telegram_media import (
MediaKind,
classify_media,
detect_mime,
fetch_media,
group_media_for_album,
optimize_image,
)
# Process each media item
processed_media: list[tuple[str, MediaKind, bytes, str]] = []
for path in media_paths:
try:
# Fetch remote URLs
if path.startswith(("http://", "https://")):
content, mime = await fetch_media(path, max_bytes=100_000_000)
kind = classify_media(mime)
# Extract filename from URL
filename = Path(path).name
else:
# Local file
file_path = Path(path)
if not file_path.exists():
logger.warning(f"Media file not found: {path}")
continue
with open(file_path, "rb") as f:
content = f.read()
mime = detect_mime(path, content)
kind = classify_media(mime)
# Extract filename from local path
filename = file_path.name
# Optimize images
if kind == MediaKind.IMAGE:
try:
content = optimize_image(path, max_bytes=6_000_000)
except Exception as e:
logger.warning(f"Image optimization failed: {e}, sending original")
processed_media.append((path, kind, content, filename))
except Exception as e:
logger.error(f"Failed to process media {path}: {e}")
continue
if not processed_media:
# No media could be processed, send text only
await self._app.bot.send_message(
chat_id=chat_id,
text=caption,
parse_mode="HTML"
)
return return
reply_params = None # Group media for album sending
if self.config.reply_to_message: media_items = [(path, kind) for path, kind, _, _ in processed_media]
reply_to_message_id = msg.metadata.get("message_id") grouping = group_media_for_album(media_items)
if reply_to_message_id:
reply_params = ReplyParameters(
message_id=reply_to_message_id,
allow_sending_without_reply=True
)
# Send media files # Handle caption length (Telegram limit: 1024 chars)
for media_path in (msg.media or []): if len(caption) > 1024:
try: # Send media without caption, then follow-up text
media_type = self._get_media_type(media_path) media_caption = None
sender = { followup_text = caption
"photo": self._app.bot.send_photo, else:
"voice": self._app.bot.send_voice, media_caption = caption
"audio": self._app.bot.send_audio, followup_text = None
}.get(media_type, self._app.bot.send_document)
param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" # Send album if grouped
with open(media_path, 'rb') as f: if grouping["album"]:
await sender( album_paths = grouping["album"]
chat_id=chat_id, album_media = []
**{param: f},
reply_parameters=reply_params for path, kind, content, filename in processed_media:
if path not in album_paths:
continue
if kind == MediaKind.IMAGE:
media_obj = InputMediaPhoto(
media=content,
caption=media_caption if len(album_media) == 0 else None,
parse_mode="HTML" if media_caption else None
) )
except Exception as e: elif kind == MediaKind.VIDEO:
filename = media_path.rsplit("/", 1)[-1] media_obj = InputMediaVideo(
logger.error("Failed to send media {}: {}", media_path, e) media=content,
await self._app.bot.send_message( 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, chat_id=chat_id,
text=f"[Failed to send: {filename}]", media=album_media
reply_parameters=reply_params
) )
# Send text content # Send separate media
if msg.content and msg.content != "[empty message]": for i, (path, kind, content, filename) in enumerate(processed_media):
for chunk in _split_message(msg.content): if path in grouping["album"]:
try: continue # Already sent in album
html = _markdown_to_telegram_html(chunk)
await self._app.bot.send_message( # Only first separate item gets caption
chat_id=chat_id, item_caption = media_caption if i == 0 else None
text=html,
parse_mode="HTML", if kind == MediaKind.IMAGE:
reply_parameters=reply_params await self._app.bot.send_photo(
) chat_id=chat_id,
except Exception as e: photo=content,
logger.warning("HTML parse failed, falling back to plain text: {}", e) caption=item_caption,
try: parse_mode="HTML" if item_caption else None
await self._app.bot.send_message( )
chat_id=chat_id, elif kind == MediaKind.VIDEO:
text=chunk, await self._app.bot.send_video(
reply_parameters=reply_params chat_id=chat_id,
) video=content,
except Exception as e2: caption=item_caption,
logger.error("Error sending Telegram message: {}", e2) 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:
return return
user = update.effective_user user = update.effective_user
await update.message.reply_text( await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n" f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n" "Send me a message and I'll respond!\n"
"Type /help to see available commands." "Type /help to see available commands."
) )
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help command, bypassing ACL so all users can access it."""
if not update.message:
return
await update.message.reply_text(
"🐈 nanobot commands:\n"
"/new — Start a new conversation\n"
"/stop — Stop the current task\n"
"/help — Show available commands"
)
@staticmethod
def _sender_id(user) -> str:
"""Build sender_id with username for allowlist matching."""
sid = str(user.id)
return f"{sid}|{user.username}" if user.username else sid
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Forward slash commands to the bus for unified handling in AgentLoop.""" """Forward slash commands to the bus for unified handling in AgentLoop."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
await self._handle_message( await self._handle_message(
sender_id=self._sender_id(update.effective_user), sender_id=str(update.effective_user.id),
chat_id=str(update.message.chat_id), chat_id=str(update.message.chat_id),
content=update.message.text, content=update.message.text,
) )
@@ -335,7 +452,11 @@ class TelegramChannel(BaseChannel):
message = update.message message = update.message
user = update.effective_user user = update.effective_user
chat_id = message.chat_id chat_id = message.chat_id
sender_id = self._sender_id(user)
# Use stable numeric ID, but keep username for allowlist compatibility
sender_id = str(user.id)
if user.username:
sender_id = f"{sender_id}|{user.username}"
# Store chat_id for replies # Store chat_id for replies
self._chat_ids[sender_id] = chat_id self._chat_ids[sender_id] = chat_id
@@ -389,45 +510,23 @@ class TelegramChannel(BaseChannel):
transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
transcription = await transcriber.transcribe(file_path) transcription = await transcriber.transcribe(file_path)
if transcription: if transcription:
logger.info("Transcribed {}: {}...", media_type, transcription[:50]) logger.info(f"Transcribed {media_type}: {transcription[:50]}...")
content_parts.append(f"[transcription: {transcription}]") content_parts.append(f"[transcription: {transcription}]")
else: else:
content_parts.append(f"[{media_type}: {file_path}]") content_parts.append(f"[{media_type}: {file_path}]")
else: else:
content_parts.append(f"[{media_type}: {file_path}]") content_parts.append(f"[{media_type}: {file_path}]")
logger.debug("Downloaded {} to {}", media_type, file_path) logger.debug(f"Downloaded {media_type} to {file_path}")
except Exception as e: except Exception as e:
logger.error("Failed to download media: {}", e) logger.error(f"Failed to download media: {e}")
content_parts.append(f"[{media_type}: download failed]") content_parts.append(f"[{media_type}: download failed]")
content = "\n".join(content_parts) if content_parts else "[empty message]" content = "\n".join(content_parts) if content_parts else "[empty message]"
logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) logger.debug(f"Telegram message from {sender_id}: {content[:50]}...")
str_chat_id = str(chat_id) str_chat_id = str(chat_id)
# Telegram media groups: buffer briefly, forward as one aggregated turn.
if media_group_id := getattr(message, "media_group_id", None):
key = f"{str_chat_id}:{media_group_id}"
if key not in self._media_group_buffers:
self._media_group_buffers[key] = {
"sender_id": sender_id, "chat_id": str_chat_id,
"contents": [], "media": [],
"metadata": {
"message_id": message.message_id, "user_id": user.id,
"username": user.username, "first_name": user.first_name,
"is_group": message.chat.type != "private",
},
}
self._start_typing(str_chat_id)
buf = self._media_group_buffers[key]
if content and content != "[empty message]":
buf["contents"].append(content)
buf["media"].extend(media_paths)
if key not in self._media_group_tasks:
self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key))
return
# Start typing indicator before processing # Start typing indicator before processing
self._start_typing(str_chat_id) self._start_typing(str_chat_id)
@@ -447,21 +546,6 @@ class TelegramChannel(BaseChannel):
} }
) )
async def _flush_media_group(self, key: str) -> None:
"""Wait briefly, then forward buffered media-group as one turn."""
try:
await asyncio.sleep(0.6)
if not (buf := self._media_group_buffers.pop(key, None)):
return
content = "\n".join(buf["contents"]) or "[empty message]"
await self._handle_message(
sender_id=buf["sender_id"], chat_id=buf["chat_id"],
content=content, media=list(dict.fromkeys(buf["media"])),
metadata=buf["metadata"],
)
finally:
self._media_group_tasks.pop(key, None)
def _start_typing(self, chat_id: str) -> None: def _start_typing(self, chat_id: str) -> None:
"""Start sending 'typing...' indicator for a chat.""" """Start sending 'typing...' indicator for a chat."""
# Cancel any existing typing task for this chat # Cancel any existing typing task for this chat
@@ -483,11 +567,11 @@ class TelegramChannel(BaseChannel):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
logger.debug("Typing indicator stopped for {}: {}", chat_id, e) logger.debug(f"Typing indicator stopped for {chat_id}: {e}")
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log polling / handler errors instead of silently swallowing them.""" """Log polling / handler errors instead of silently swallowing them."""
logger.error("Telegram error: {}", context.error) logger.error(f"Telegram error: {context.error}")
def _get_extension(self, media_type: str, mime_type: str | None) -> str: def _get_extension(self, media_type: str, mime_type: str | None) -> str:
"""Get file extension based on media type.""" """Get file extension based on media type."""
+286
View File
@@ -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]
}
+13 -27
View File
@@ -200,40 +200,23 @@ def onboard():
def _make_provider(config: Config): def _make_provider(config: Config):
"""Create the appropriate LLM provider from config.""" """Create LLM provider from config. Uses OAuth for subscription tokens."""
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers import create_provider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
p = config.get_provider()
model = config.agents.defaults.model model = config.agents.defaults.model
provider_name = config.get_provider_name(model) if not (p and p.api_key) and not model.startswith("bedrock/"):
p = config.get_provider(model)
# OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
if provider_name == "custom":
return CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
from nanobot.providers.registry import find_by_name
spec = find_by_name(provider_name)
if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
console.print("[red]Error: No API key configured.[/red]") console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section") console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1) raise typer.Exit(1)
return LiteLLMProvider( return create_provider(
api_key=p.api_key if p else None, api_key=p.api_key if p else "",
api_base=config.get_api_base(model), model=model,
default_model=model, api_base=config.get_api_base(),
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
provider_name=provider_name, provider_name=config.get_provider_name(),
thinking_budget=config.agents.defaults.thinking_budget,
) )
@@ -287,6 +270,7 @@ def gateway(
exec_config=config.tools.exec, exec_config=config.tools.exec,
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
session_manager=session_manager, session_manager=session_manager,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels, channels_config=config.channels,
@@ -445,6 +429,7 @@ def agent(
exec_config=config.tools.exec, exec_config=config.tools.exec,
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels, channels_config=config.channels,
) )
@@ -935,6 +920,7 @@ def cron_run(
brave_api_key=config.tools.web.search.api_key or None, brave_api_key=config.tools.web.search.api_key or None,
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,
enable_memory_tool=config.tools.enable_memory_tool,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
channels_config=config.channels, channels_config=config.channels,
) )
+93
View File
@@ -0,0 +1,93 @@
"""OAuth CLI commands for subscription authentication."""
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
oauth_app = typer.Typer(help="Manage OAuth authentication for subscription-based providers")
console = Console()
@oauth_app.command("login")
def login(
provider: str = typer.Argument("anthropic", help="Provider name"),
token: Optional[str] = typer.Option(None, "--token", "-t", help="OAuth token (from claude setup-token)"),
):
"""Login to a provider using OAuth.
For Anthropic Claude Max/Pro, run 'claude setup-token' and paste the token here.
Example:
nanobot oauth login anthropic --token sk-ant-oat01-xxx
"""
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
if provider != "anthropic":
console.print(f"[red]OAuth login for {provider} not yet supported[/red]")
return
if not token:
console.print("Please provide your OAuth token:")
console.print(" 1. Run: claude setup-token")
console.print(" 2. Copy the sk-ant-oat01-... token")
console.print(" 3. Run: nanobot oauth login anthropic --token <your-token>")
console.print()
token = typer.prompt("Token", hide_input=True)
if not token or "sk-ant-oat" not in token:
console.print("[red]Invalid token. Must contain sk-ant-oat[/red]")
return
store = OAuthStore(Path.home() / ".nanobot")
creds = OAuthCredentials(
access_token=token,
token_type="token" # setup-token doesn't expire
)
store.save(provider, creds)
console.print(f"[green]Successfully saved {provider} OAuth credentials![/green]")
@oauth_app.command("status")
def status():
"""Show OAuth credential status."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
providers = ["anthropic"]
found_any = False
for provider in providers:
creds = store.load(provider)
if creds:
found_any = True
st = "valid"
if creds.is_expired:
st = "EXPIRED"
elif creds.expires_soon:
st = "expires soon"
token_preview = creds.access_token[:20] + "..."
console.print(f" {provider}: {token_preview} ({st})")
if not found_any:
console.print("No OAuth credentials configured.")
console.print("Run: nanobot oauth login anthropic --token <token>")
@oauth_app.command("logout")
def logout(
provider: str = typer.Argument("anthropic", help="Provider name"),
):
"""Remove OAuth credentials for a provider."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
if store.delete(provider):
console.print(f"[green]Removed {provider} OAuth credentials[/green]")
else:
console.print(f"No credentials found for {provider}")
+20 -2
View File
@@ -11,12 +11,29 @@ def get_config_path() -> Path:
return Path.home() / ".nanobot" / "config.json" return Path.home() / ".nanobot" / "config.json"
def _get_oauth_store_dir() -> Path:
"""Get the OAuth store directory."""
return Path.home() / ".nanobot"
def get_data_dir() -> Path: def get_data_dir() -> Path:
"""Get the nanobot data directory.""" """Get the nanobot data directory."""
from nanobot.utils.helpers import get_data_path from nanobot.utils.helpers import get_data_path
return get_data_path() return get_data_path()
def _inject_oauth_credentials(config: Config) -> Config:
"""Inject OAuth credentials from store into config if available."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(_get_oauth_store_dir())
creds = store.load("anthropic")
if creds and creds.access_token and not creds.is_expired:
config.providers.anthropic.api_key = creds.access_token
return config
def load_config(config_path: Path | None = None) -> Config: def load_config(config_path: Path | None = None) -> Config:
""" """
Load configuration from file or create default. Load configuration from file or create default.
@@ -34,12 +51,13 @@ def load_config(config_path: Path | None = None) -> Config:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
data = _migrate_config(data) data = _migrate_config(data)
return Config.model_validate(data) config = Config.model_validate(data)
return _inject_oauth_credentials(config)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
print(f"Warning: Failed to load config from {path}: {e}") print(f"Warning: Failed to load config from {path}: {e}")
print("Using default configuration.") print("Using default configuration.")
return Config() return _inject_oauth_credentials(Config())
def save_config(config: Config, config_path: Path | None = None) -> None: def save_config(config: Config, config_path: Path | None = None) -> None:
+59
View File
@@ -0,0 +1,59 @@
"""OAuth credential storage."""
import json
from pathlib import Path
from typing import Any
from nanobot.config.schema import OAuthCredentials
class OAuthStore:
"""Stores OAuth credentials in a JSON file."""
FILENAME = "oauth-credentials.json"
def __init__(self, config_dir: Path):
self.config_dir = config_dir
self.file_path = config_dir / self.FILENAME
def _load_all(self) -> dict[str, Any]:
"""Load all credentials from file."""
if not self.file_path.exists():
return {}
with open(self.file_path, "r") as f:
return json.load(f)
def _save_all(self, data: dict[str, Any]) -> None:
"""Save all credentials to file."""
self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w") as f:
json.dump(data, f, indent=2)
# Secure permissions
self.file_path.chmod(0o600)
def save(self, provider: str, credentials: OAuthCredentials) -> None:
"""Save credentials for a provider."""
data = self._load_all()
data[provider] = credentials.model_dump()
self._save_all(data)
def load(self, provider: str) -> OAuthCredentials | None:
"""Load credentials for a provider."""
data = self._load_all()
if provider not in data:
return None
return OAuthCredentials(**data[provider])
def delete(self, provider: str) -> bool:
"""Delete credentials for a provider."""
data = self._load_all()
if provider not in data:
return False
del data[provider]
self._save_all(data)
return True
+53
View File
@@ -226,6 +226,7 @@ class AgentDefaults(Base):
temperature: float = 0.1 temperature: float = 0.1
max_tool_iterations: int = 40 max_tool_iterations: int = 40
memory_window: int = 100 memory_window: int = 100
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
class AgentsConfig(Base): class AgentsConfig(Base):
@@ -234,12 +235,42 @@ class AgentsConfig(Base):
defaults: AgentDefaults = Field(default_factory=AgentDefaults) defaults: AgentDefaults = Field(default_factory=AgentDefaults)
class OAuthCredentials(BaseModel):
"""OAuth token credentials for subscription-based auth."""
access_token: str = ""
refresh_token: str = ""
expires_at: int = 0 # Unix timestamp
token_type: str = "oauth" # "oauth" or "token" (setup-token)
@property
def is_oauth_token(self) -> bool:
"""Check if this is an OAuth token (vs regular API key)."""
return "sk-ant-oat" in self.access_token
@property
def is_expired(self) -> bool:
"""Check if token has expired."""
import time
if self.expires_at == 0:
return False # No expiry set (setup-token)
return time.time() > self.expires_at
@property
def expires_soon(self) -> bool:
"""Check if token expires within 10 minutes."""
import time
if self.expires_at == 0:
return False
return time.time() > (self.expires_at - 600)
class ProviderConfig(Base): class ProviderConfig(Base):
"""LLM provider configuration.""" """LLM provider configuration."""
api_key: str = "" api_key: str = ""
api_base: str | None = None api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
oauth_credentials: OAuthCredentials | None = None
class ProvidersConfig(Base): class ProvidersConfig(Base):
@@ -279,6 +310,26 @@ class GatewayConfig(Base):
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
class HooksConfig(Base):
"""Webhook endpoint configuration."""
enabled: bool = False
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
path: str = "/hooks" # URL path for the endpoint
timeout_seconds: int = 120 # Max time to wait for agent response
def resolve_token(self, provided: str) -> str | None:
"""Return token name if provided secret matches, else None."""
for name, secret in self.tokens.items():
if secret == provided:
return name
return None
@property
def has_tokens(self) -> bool:
"""True if at least one token is configured."""
return bool(self.tokens)
class WebSearchConfig(Base): class WebSearchConfig(Base):
"""Web search tool configuration.""" """Web search tool configuration."""
@@ -316,6 +367,7 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=WebToolsConfig) web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
enable_memory_tool: bool = True # If true, enable Anthropic's native memory tool
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
@@ -326,6 +378,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
+96 -93
View File
@@ -9,64 +9,62 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger from loguru import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider from nanobot.session.manager import SessionManager
_HEARTBEAT_TOOL = [ # Default interval: 30 minutes
{ DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
"type": "function",
"function": { # The prompt sent to agent during heartbeat
"name": "heartbeat", HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists).
"description": "Report heartbeat decision after reviewing tasks.", Follow any instructions or tasks listed there.
"parameters": { If nothing needs attention, reply with just: HEARTBEAT_OK"""
"type": "object",
"properties": { # Token that indicates "nothing to do"
"action": { HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"
"type": "string",
"enum": ["skip", "run"],
"description": "skip = nothing to do, run = has active tasks", def _is_heartbeat_empty(content: str | None) -> bool:
}, """Check if HEARTBEAT.md has no actionable content."""
"tasks": { if not content:
"type": "string", return True
"description": "Natural-language summary of active tasks (required for run)",
}, # Lines to skip: empty, headers, HTML comments, empty checkboxes
}, skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
"required": ["action"],
}, for line in content.split("\n"):
}, line = line.strip()
} if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
] continue
return False # Found actionable content
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.
Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual The agent reads HEARTBEAT.md from the workspace and executes any
tool call — whether there are active tasks. This avoids free-text parsing tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
and the unreliable HEARTBEAT_OK token.
Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
``on_execute`` callback runs the task through the full agent loop and
returns the result to deliver.
""" """
def __init__( def __init__(
self, self,
workspace: Path, workspace: Path,
provider: LLMProvider, on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
model: str, interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = 30 * 60,
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.provider = provider self.on_heartbeat = on_heartbeat
self.model = model
self.on_execute = on_execute
self.on_notify = on_notify
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
@@ -75,48 +73,23 @@ class HeartbeatService:
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."""
if self.heartbeat_file.exists(): if self.heartbeat_file.exists():
try: try:
return self.heartbeat_file.read_text(encoding="utf-8") return self.heartbeat_file.read_text()
except Exception: except Exception:
return None return None
return None return None
async def _decide(self, content: str) -> tuple[str, str]:
"""Phase 1: ask LLM to decide skip/run via virtual tool call.
Returns (action, tasks) where action is 'skip' or 'run'.
"""
response = await self.provider.chat(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
f"{content}"
)},
],
tools=_HEARTBEAT_TOOL,
model=self.model,
)
if not response.has_tool_calls:
return "skip", ""
args = response.tool_calls[0].arguments
return args.get("action", "skip"), args.get("tasks", "")
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
if self._running:
logger.warning("Heartbeat already running")
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("Heartbeat started (every {}s)", self.interval_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."""
@@ -135,39 +108,69 @@ class HeartbeatService:
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error("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()
if not content:
logger.debug("Heartbeat: HEARTBEAT.md missing or empty") # Skip if HEARTBEAT.md is empty or doesn't exist
if _is_heartbeat_empty(content):
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
return return
logger.info("Heartbeat: checking for tasks...") logger.info("Heartbeat: user idle, checking for tasks...")
try: if self.on_heartbeat:
action, tasks = await self._decide(content) try:
# Call with suppress_output metadata
await self.on_heartbeat(
HEARTBEAT_PROMPT,
metadata={"suppress_output": True}
)
if action != "run": # Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
logger.info("Heartbeat: OK (nothing to report)") logger.info("Heartbeat: completed")
return
logger.info("Heartbeat: tasks found, executing...") except Exception as e:
if self.on_execute: logger.error(f"Heartbeat execution failed: {e}")
response = await self.on_execute(tasks)
if response and self.on_notify:
logger.info("Heartbeat: completed, delivering response")
await self.on_notify(response)
except Exception:
logger.exception("Heartbeat execution failed")
async def trigger_now(self) -> str | None: async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat.""" """Manually trigger a heartbeat."""
content = self._read_heartbeat_file() if self.on_heartbeat:
if not content: return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
return None return None
action, tasks = await self._decide(content)
if action != "run" or not self.on_execute:
return None
return await self.on_execute(tasks)
View File
+139
View File
@@ -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)
+43 -3
View File
@@ -1,7 +1,47 @@
"""LLM provider abstraction module.""" """Provider module exports."""
from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.registry import should_use_oauth_provider
__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"] __all__ = [
"LLMProvider",
"LLMResponse",
"ToolCallRequest",
"LiteLLMProvider",
"OpenAICodexProvider",
"AnthropicOAuthProvider",
"create_provider",
]
def create_provider(
api_key: str,
model: str,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
provider_name: str | None = None,
thinking_budget: int = 0,
) -> LLMProvider:
"""Factory function to create appropriate provider.
Automatically selects AnthropicOAuthProvider for OAuth tokens,
LiteLLMProvider for everything else.
"""
if should_use_oauth_provider(api_key, model):
return AnthropicOAuthProvider(
oauth_token=api_key,
default_model=model,
api_base=api_base,
thinking_budget=thinking_budget,
)
return LiteLLMProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
provider_name=provider_name,
)
+497
View File
@@ -0,0 +1,497 @@
"""Anthropic OAuth provider - direct API calls with Bearer auth.
This provider bypasses litellm to properly handle OAuth tokens
which require Authorization: Bearer header instead of x-api-key.
"""
import json
from typing import Any
import httpx
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.oauth_utils import get_auth_headers
class AnthropicOAuthProvider(LLMProvider):
"""
Anthropic provider using OAuth token authentication.
Unlike the LiteLLM provider, this calls the Anthropic API directly
with proper Bearer token authentication for Claude Max/Pro subscriptions.
"""
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
def __init__(
self,
oauth_token: str,
default_model: str = "claude-opus-4-5",
api_base: str | None = None,
thinking_budget: int = 0,
):
super().__init__(api_key=None, api_base=api_base)
self.oauth_token = oauth_token
self.default_model = default_model
self.thinking_budget = thinking_budget
self._client: httpx.AsyncClient | None = None
def _get_headers(self) -> dict[str, str]:
"""Get request headers with Bearer auth."""
return get_auth_headers(self.oauth_token, is_oauth=True)
def _get_api_url(self) -> str:
"""Get API endpoint URL."""
if self.api_base:
return f"{self.api_base.rstrip('/')}/v1/messages"
return self.ANTHROPIC_API_URL
@staticmethod
def _normalize_model(model: str) -> str:
"""Normalize model name for the Anthropic API.
Anthropic model IDs use hyphens (claude-sonnet-4-5), but users often
write dots (claude-sonnet-4.5). Normalize so both work.
"""
return model.replace(".", "-")
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._client is None:
self._client = httpx.AsyncClient(timeout=300.0)
return self._client
def _prepare_messages(
self,
messages: list[dict[str, Any]]
) -> tuple[str | None, list[dict[str, Any]]]:
"""Prepare messages: extract system prompt and convert OpenAI format to Anthropic.
The agent loop produces messages in OpenAI format:
- assistant msgs with tool_calls [{type:"function", function:{name, arguments}}]
- tool role msgs with tool_call_id, name, content
Anthropic API expects:
- assistant msgs with content blocks [{type:"tool_use", id, name, input}]
- user msgs with content blocks [{type:"tool_result", tool_use_id, content}]
Returns (system_prompt, anthropic_messages)
"""
system_parts = []
converted: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role == "system":
system_parts.append(msg.get("content", ""))
continue
if role == "assistant" and msg.get("tool_calls"):
# Convert OpenAI tool_calls to Anthropic content blocks
content_blocks: list[dict[str, Any]] = []
# Preserve thinking blocks (list=raw API blocks with signatures, str=legacy)
rc = msg.get("reasoning_content")
if isinstance(rc, list):
content_blocks.extend(rc)
elif isinstance(rc, str) and rc:
content_blocks.append({"type": "thinking", "thinking": rc})
text = msg.get("content")
if text:
content_blocks.append({"type": "text", "text": text})
for tc in msg["tool_calls"]:
func = tc.get("function", {})
args = func.get("arguments", "{}")
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {}
content_blocks.append({
"type": "tool_use",
"id": tc.get("id", ""),
"name": func.get("name", ""),
"input": args,
})
converted.append({"role": "assistant", "content": content_blocks})
continue
if role == "assistant" and msg.get("reasoning_content"):
# Plain assistant message with thinking (no tool calls)
rc = msg["reasoning_content"]
if isinstance(rc, list):
content_blocks = list(rc)
else:
content_blocks = [{"type": "thinking", "thinking": rc}]
text = msg.get("content")
if text:
content_blocks.append({"type": "text", "text": text})
converted.append({"role": "assistant", "content": content_blocks})
continue
if role == "tool":
# Convert tool result to Anthropic user message with tool_result block
tool_result_block = {
"type": "tool_result",
"tool_use_id": msg.get("tool_call_id", ""),
"content": msg.get("content", ""),
}
# Merge into previous user message if it already has tool_result blocks
if converted and converted[-1].get("role") == "user":
prev_content = converted[-1].get("content")
if isinstance(prev_content, list):
prev_content.append(tool_result_block)
continue
converted.append({"role": "user", "content": [tool_result_block]})
continue
if role == "user":
content = msg.get("content", "")
# Convert OpenAI image_url blocks to Anthropic image blocks
if isinstance(content, list):
content = self._convert_image_blocks(content)
# Merge text into previous user message if it has tool_result blocks
# (handles the "Reflect on the results" interleaved message)
if converted and converted[-1].get("role") == "user":
prev_content = converted[-1].get("content")
if isinstance(prev_content, list):
if isinstance(content, str):
prev_content.append({"type": "text", "text": content})
elif isinstance(content, list):
prev_content.extend(content)
continue
converted.append({"role": role, "content": content})
continue
# Pass through other messages (assistant without tool_calls, etc.)
converted.append(msg)
system_prompt = "\n\n".join(system_parts)
return system_prompt, converted
@staticmethod
def _convert_image_blocks(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI image_url blocks to Anthropic image blocks.
OpenAI format: {"type": "image_url", "image_url": {"url": "data:mime;base64,DATA"}}
Anthropic format: {"type": "image", "source": {"type": "base64", "media_type": "mime", "data": "DATA"}}
"""
converted = []
for block in content:
if block.get("type") == "image_url":
url = block.get("image_url", {}).get("url", "")
if url.startswith("data:") and ";base64," in url:
header, data = url.split(";base64,", 1)
media_type = header.removeprefix("data:")
converted.append({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
})
else:
converted.append({
"type": "image",
"source": {"type": "url", "url": url},
})
else:
converted.append(block)
return converted
def _convert_tools_to_anthropic(
self,
tools: list[dict[str, Any]] | list[Any] | None
) -> list[dict[str, Any]] | None:
"""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:
return None
anthropic_tools = []
for tool in tools:
# Convert tool objects to dicts first
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({
"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, etc.)
anthropic_tools.append(tool_dict)
return anthropic_tools if anthropic_tools else None
async def _make_request(
self,
messages: list[dict[str, Any]],
system: str | None = None,
model: str = "claude-opus-4-5",
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[dict[str, Any]] | None = None,
thinking_budget_override: int | None = None,
context_management: dict[str, Any] | None = None,
beta_flags: set[str] | None = None,
) -> dict[str, Any]:
"""Make request to Anthropic API."""
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] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
}
# Extended thinking: temperature must be 1 when enabled
effective_thinking = thinking_budget_override if thinking_budget_override is not None else self.thinking_budget
if effective_thinking > 0:
payload["temperature"] = 1
# max_tokens must exceed budget_tokens
if max_tokens <= effective_thinking:
payload["max_tokens"] = effective_thinking + 4096
payload["thinking"] = {
"type": "enabled",
"budget_tokens": effective_thinking,
}
else:
payload["temperature"] = temperature
if system:
payload["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
if 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(
"Anthropic request: model={} max_tokens={} thinking={} tools={} context_mgmt={} beta={}",
payload.get("model"), payload.get("max_tokens"),
payload.get("thinking", "disabled"),
len(payload.get("tools", [])),
edit_types or "none",
headers.get("anthropic-beta", "none"),
)
response = await client.post(
self._get_api_url(),
headers=headers,
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:
error_text = response.text
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
return response.json()
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | list[Any] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
context_management: dict[str, Any] | None = None,
) -> LLMResponse:
"""Send chat completion request to Anthropic API."""
model = model or self.default_model
# Strip provider prefix if present (e.g. "anthropic/claude-opus-4-5" -> "claude-opus-4-5")
if "/" in model:
model = model.split("/")[-1]
# Normalize dots to hyphens (claude-sonnet-4.5 -> claude-sonnet-4-5)
model = self._normalize_model(model)
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)
# Per-call thinking override (None = use instance default)
effective_thinking = self.thinking_budget if thinking_budget is None else thinking_budget
try:
response = await self._make_request(
messages=prepared_messages,
system=system,
model=model,
max_tokens=max_tokens,
temperature=temperature,
tools=anthropic_tools,
thinking_budget_override=effective_thinking,
context_management=context_management,
beta_flags=beta_flags,
)
return self._parse_response(response)
except Exception as e:
return LLMResponse(
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
def _parse_response(self, response: dict[str, Any]) -> LLMResponse:
"""Parse Anthropic API response."""
content_blocks = response.get("content", [])
text_content = ""
thinking_blocks: list[dict[str, Any]] = []
tool_calls = []
for block in content_blocks:
if block.get("type") == "thinking":
# Preserve full block including signature for multi-turn replay
thinking_blocks.append(block)
elif block.get("type") == "text":
text_content += block.get("text", "")
elif block.get("type") == "tool_use":
tool_calls.append(ToolCallRequest(
id=block.get("id", ""),
name=block.get("name", ""),
arguments=block.get("input", {}),
))
usage = {}
if "usage" in response:
usage = {
"prompt_tokens": response["usage"].get("input_tokens", 0),
"completion_tokens": response["usage"].get("output_tokens", 0),
"total_tokens": (
response["usage"].get("input_tokens", 0) +
response["usage"].get("output_tokens", 0)
),
}
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
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(
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
"input={} output={} cache_write={} cache_read={} tokens",
stop_reason, len(tool_calls), thinking_chars,
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(
content=text_content or None,
tool_calls=tool_calls,
finish_reason=stop_reason,
usage=usage,
reasoning_content=thinking_blocks or None,
)
def get_default_model(self) -> str:
"""Get the default model."""
return self.default_model
async def close(self):
"""Close the HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
+3 -1
View File
@@ -20,7 +20,7 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list) tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop" finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict) usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. reasoning_content: Any = None # str for Kimi/DeepSeek-R1; list[dict] for Anthropic thinking blocks
@property @property
def has_tool_calls(self) -> bool: def has_tool_calls(self) -> bool:
@@ -88,6 +88,8 @@ class LLMProvider(ABC):
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,
context_management: dict[str, Any] | None = None,
) -> LLMResponse: ) -> LLMResponse:
""" """
Send a chat completion request. Send a chat completion request.
+2
View File
@@ -178,6 +178,8 @@ class LiteLLMProvider(LLMProvider):
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,
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.
+38
View File
@@ -0,0 +1,38 @@
"""OAuth utility functions for Anthropic subscription auth."""
from typing import Any
def is_oauth_token(token: str | None) -> bool:
"""Check if token is an OAuth token (vs regular API key).
OAuth tokens from Claude Max/Pro contain 'sk-ant-oat' prefix.
Regular API keys use 'sk-ant-api03' or similar.
"""
if not token:
return False
return "sk-ant-oat" in token
def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
"""Get authentication headers for Anthropic API.
OAuth tokens require Authorization: Bearer header.
Regular API keys use x-api-key header.
"""
headers: dict[str, str] = {
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
if is_oauth:
headers["Authorization"] = f"Bearer {token}"
# Required headers to mimic Claude Code client
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27"
headers["anthropic-dangerous-direct-browser-access"] = "true"
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
headers["x-app"] = "cli"
else:
headers["x-api-key"] = token
return headers
+21
View File
@@ -460,3 +460,24 @@ def find_by_name(name: str) -> ProviderSpec | None:
if spec.name == name: if spec.name == name:
return spec return spec
return None return None
def should_use_oauth_provider(api_key: str | None, model: str) -> bool:
"""Determine if OAuth provider should be used.
OAuth provider is used when:
1. API key is an OAuth token (contains 'sk-ant-oat')
2. Model is an Anthropic model (contains 'claude' or 'anthropic')
"""
if not api_key:
return False
if "sk-ant-oat" not in api_key:
return False
model_lower = model.lower()
anthropic_spec = find_by_name("anthropic")
if anthropic_spec:
return any(kw in model_lower for kw in anthropic_spec.keywords)
return False
+72 -70
View File
@@ -1,7 +1,6 @@
"""Session management for conversation history.""" """Session management for conversation history."""
import json import json
import shutil
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -16,20 +15,15 @@ from nanobot.utils.helpers import ensure_dir, safe_filename
class Session: class Session:
""" """
A conversation session. A conversation session.
Stores messages in JSONL format for easy reading and persistence. Stores messages in JSONL format for easy reading and persistence.
Important: Messages are append-only for LLM cache efficiency.
The consolidation process writes summaries to MEMORY.md/HISTORY.md
but does NOT modify the messages list or get_history() output.
""" """
key: str # channel:chat_id key: str # channel:chat_id
messages: list[dict[str, Any]] = field(default_factory=list) messages: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None: def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session.""" """Add a message to the session."""
@@ -41,56 +35,57 @@ 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 = 500) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input, aligned to a user turn."""
unconsolidated = self.messages[self.last_consolidated:]
sliced = unconsolidated[-max_messages:]
# Drop leading non-user messages to avoid orphaned tool_result blocks def add_raw_message(self, msg: dict[str, Any]) -> None:
for i, m in enumerate(sliced): """Add a pre-formed message dict to the session, preserving all fields."""
if m.get("role") == "user": stored = dict(msg)
sliced = sliced[i:] if "timestamp" not in stored:
break stored["timestamp"] = datetime.now().isoformat()
self.messages.append(stored)
self.updated_at = datetime.now()
out: list[dict[str, Any]] = [] # Fields that are valid in the Anthropic/OpenAI messages API.
for m in sliced: # Everything else (timestamp, tools_used, etc.) is internal metadata.
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} _API_FIELDS = {"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}
for k in ("tool_calls", "tool_call_id", "name"):
if k in m: def get_history(self) -> list[dict[str, Any]]:
entry[k] = m[k] """
out.append(entry) Get full message history for LLM context.
return out
The server-side context editing API (clear_tool_uses_20250919) handles
trimming old tool chains safely at token thresholds, so we send the full
history and let the server decide what to drop.
Returns:
List of messages in LLM format (API-relevant fields only).
"""
return [
{k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
for m in self.messages
]
def clear(self) -> None: def clear(self) -> None:
"""Clear all messages and reset session to initial state.""" """Clear all messages in the session."""
self.messages = [] self.messages = []
self.last_consolidated = 0
self.updated_at = datetime.now() self.updated_at = datetime.now()
class SessionManager: class SessionManager:
""" """
Manages conversation sessions. Manages conversation sessions.
Sessions are stored as JSONL files in the sessions directory. Sessions are stored as JSONL files in the sessions directory.
""" """
def __init__(self, workspace: Path): def __init__(self, workspace: Path):
self.workspace = workspace self.workspace = workspace
self.sessions_dir = ensure_dir(self.workspace / "sessions") self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
self._cache: dict[str, Session] = {} self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session.""" """Get the file path for a session."""
safe_key = safe_filename(key.replace(":", "_")) safe_key = safe_filename(key.replace(":", "_"))
return self.sessions_dir / f"{safe_key}.jsonl" return self.sessions_dir / f"{safe_key}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_"))
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
@@ -102,9 +97,11 @@ class SessionManager:
Returns: Returns:
The session. The session.
""" """
# Check cache
if key in self._cache: if key in self._cache:
return self._cache[key] return self._cache[key]
# Try to load from disk
session = self._load(key) session = self._load(key)
if session is None: if session is None:
session = Session(key=key) session = Session(key=key)
@@ -115,72 +112,78 @@ class SessionManager:
def _load(self, key: str) -> Session | None: def _load(self, key: str) -> Session | None:
"""Load a session from disk.""" """Load a session from disk."""
path = self._get_session_path(key) path = self._get_session_path(key)
if not path.exists():
legacy_path = self._get_legacy_session_path(key)
if legacy_path.exists():
try:
shutil.move(str(legacy_path), str(path))
logger.info("Migrated session {} from legacy path", key)
except Exception:
logger.exception("Failed to migrate session {}", key)
if not path.exists(): if not path.exists():
return None return None
try: try:
messages = [] messages = []
metadata = {} metadata = {}
created_at = None created_at = None
last_consolidated = 0
with open(path) as f:
with open(path, encoding="utf-8") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
data = json.loads(line) data = json.loads(line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
last_consolidated = data.get("last_consolidated", 0)
else: else:
messages.append(data) messages.append(data)
return Session( return Session(
key=key, key=key,
messages=messages, messages=messages,
created_at=created_at or datetime.now(), created_at=created_at or datetime.now(),
metadata=metadata, metadata=metadata
last_consolidated=last_consolidated
) )
except Exception as e: except Exception as e:
logger.warning("Failed to load session {}: {}", key, e) logger.warning(f"Failed to load session {key}: {e}")
return None return None
def save(self, session: Session) -> None: def save(self, session: Session) -> None:
"""Save a session to disk.""" """Save a session to disk."""
path = self._get_session_path(session.key) path = self._get_session_path(session.key)
with open(path, "w", encoding="utf-8") as f: with open(path, "w") as f:
# Write metadata first
metadata_line = { metadata_line = {
"_type": "metadata", "_type": "metadata",
"key": session.key,
"created_at": session.created_at.isoformat(), "created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(), "updated_at": session.updated_at.isoformat(),
"metadata": session.metadata, "metadata": session.metadata
"last_consolidated": session.last_consolidated
} }
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") f.write(json.dumps(metadata_line) + "\n")
# Write messages
for msg in session.messages: for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n") f.write(json.dumps(msg) + "\n")
self._cache[session.key] = session self._cache[session.key] = session
def invalidate(self, key: str) -> None: def delete(self, key: str) -> bool:
"""Remove a session from the in-memory cache.""" """
Delete a session.
Args:
key: Session key.
Returns:
True if deleted, False if not found.
"""
# Remove from cache
self._cache.pop(key, None) self._cache.pop(key, None)
# Remove file
path = self._get_session_path(key)
if path.exists():
path.unlink()
return True
return False
def list_sessions(self) -> list[dict[str, Any]]: def list_sessions(self) -> list[dict[str, Any]]:
""" """
@@ -194,14 +197,13 @@ class SessionManager:
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
try: try:
# Read just the metadata line # Read just the metadata line
with open(path, encoding="utf-8") as f: with open(path) as f:
first_line = f.readline().strip() first_line = f.readline().strip()
if first_line: if first_line:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1)
sessions.append({ sessions.append({
"key": key, "key": path.stem.replace("_", ":"),
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"path": str(path) "path": str(path)
+26 -35
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.1.4.post2" version = "0.1.3.post7"
description = "A lightweight personal AI assistant framework" description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "MIT"} license = {text = "MIT"}
@@ -17,42 +17,34 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"typer>=0.20.0,<1.0.0", "typer>=0.9.0",
"litellm>=1.81.5,<2.0.0", "litellm>=1.0.0",
"pydantic>=2.12.0,<3.0.0", "pydantic>=2.0.0",
"pydantic-settings>=2.12.0,<3.0.0", "pydantic-settings>=2.0.0",
"websockets>=16.0,<17.0", "websockets>=12.0",
"websocket-client>=1.9.0,<2.0.0", "websocket-client>=1.6.0",
"httpx>=0.28.0,<1.0.0", "httpx[socks]>=0.25.0",
"oauth-cli-kit>=0.1.3,<1.0.0", "loguru>=0.7.0",
"loguru>=0.7.3,<1.0.0", "readability-lxml>=0.8.0",
"readability-lxml>=0.8.4,<1.0.0", "rich>=13.0.0",
"rich>=14.0.0,<15.0.0", "croniter>=2.0.0",
"croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.4.0",
"dingtalk-stream>=0.24.0,<1.0.0", "python-telegram-bot[socks]>=21.0",
"python-telegram-bot[socks]>=22.0,<23.0", "lark-oapi>=1.0.0",
"lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0",
"socksio>=1.0.0,<2.0.0", "python-socketio>=5.11.0",
"python-socketio>=5.16.0,<6.0.0", "msgpack>=1.0.8",
"msgpack>=1.1.0,<2.0.0", "slack-sdk>=3.26.0",
"slack-sdk>=3.39.0,<4.0.0", "qq-botpy>=1.0.0",
"slackify-markdown>=0.2.0,<1.0.0", "python-socks[asyncio]>=2.4.0",
"qq-botpy>=1.2.0,<2.0.0", "prompt-toolkit>=3.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0", "vncdotool>=1.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
matrix = [
"matrix-nio[e2e]>=0.25.2",
"mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0",
]
dev = [ dev = [
"pytest>=9.0.0,<10.0.0", "pytest>=7.0.0",
"pytest-asyncio>=1.3.0,<2.0.0", "pytest-asyncio>=0.21.0",
"ruff>=0.1.0", "ruff>=0.1.0",
] ]
@@ -69,11 +61,10 @@ packages = ["nanobot"]
[tool.hatch.build.targets.wheel.sources] [tool.hatch.build.targets.wheel.sources]
"nanobot" = "nanobot" "nanobot" = "nanobot"
# Include non-Python files in skills and templates # Include non-Python files in skills
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
"nanobot/**/*.py", "nanobot/**/*.py",
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md", "nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh", "nanobot/skills/**/*.sh",
] ]
+102
View File
@@ -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"
+262
View File
@@ -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
+62
View File
@@ -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"
+88
View File
@@ -0,0 +1,88 @@
"""Test Anthropic OAuth provider."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.base import LLMResponse
@pytest.fixture
def provider():
"""Create provider with test OAuth token."""
return AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-opus-4-5"
)
def test_provider_init(provider):
"""Provider should initialize with OAuth token."""
assert provider.oauth_token == "sk-ant-oat01-test-token"
assert provider.default_model == "claude-opus-4-5"
def test_provider_uses_bearer_auth(provider):
"""Provider should use Bearer auth, not x-api-key."""
headers = provider._get_headers()
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
assert "x-api-key" not in headers
@pytest.mark.asyncio
async def test_chat_prepends_system_prompt(provider):
"""Chat should prepend Claude Code identity to system prompt."""
messages = [{"role": "user", "content": "Hello"}]
with patch.object(provider, "_make_request", new_callable=AsyncMock) as mock:
mock.return_value = {"content": [{"type": "text", "text": "Hi"}], "stop_reason": "end_turn"}
await provider.chat(messages)
call_args = mock.call_args
system = call_args[1]["system"]
assert "Claude Code" in system
def test_parse_response_text(provider):
"""Should parse text response correctly."""
response = {
"content": [{"type": "text", "text": "Hello world"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = provider._parse_response(response)
assert result.content == "Hello world"
assert result.finish_reason == "end_turn"
assert result.usage["prompt_tokens"] == 10
def test_parse_response_tool_calls(provider):
"""Should parse tool call response correctly."""
response = {
"content": [
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "/tmp/test"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = provider._parse_response(response)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "read_file"
assert result.tool_calls[0].arguments == {"path": "/tmp/test"}
def test_convert_tools_to_anthropic(provider):
"""Should convert OpenAI-format tools to Anthropic format."""
openai_tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
}
}
]
anthropic_tools = provider._convert_tools_to_anthropic(openai_tools)
assert len(anthropic_tools) == 1
assert anthropic_tools[0]["name"] == "read_file"
assert "input_schema" in anthropic_tools[0]
@@ -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"}
+56
View File
@@ -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"
+103
View File
@@ -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"
+59
View File
@@ -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()
+55
View File
@@ -0,0 +1,55 @@
"""Test OAuth CLI commands."""
import pytest
import tempfile
from pathlib import Path
from typer.testing import CliRunner
from nanobot.cli.oauth import oauth_app
@pytest.fixture
def runner():
return CliRunner()
def test_oauth_login_help(runner):
"""Login command should have help text."""
result = runner.invoke(oauth_app, ["login", "--help"])
assert result.exit_code == 0
assert "token" in result.output.lower()
def test_oauth_status_no_credentials(runner, tmp_path, monkeypatch):
"""Status should show no credentials when none exist."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "No OAuth credentials" in result.output
def test_oauth_login_and_status(runner, tmp_path, monkeypatch):
"""Login should save credentials, status should show them."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
assert result.exit_code == 0
assert "Successfully saved" in result.output
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "sk-ant-oat01-test-x" in result.output
def test_oauth_logout(runner, tmp_path, monkeypatch):
"""Logout should remove credentials."""
monkeypatch.setenv("HOME", str(tmp_path))
runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
result = runner.invoke(oauth_app, ["logout"])
assert result.exit_code == 0
assert "Removed" in result.output
def test_oauth_login_invalid_token(runner, tmp_path, monkeypatch):
"""Login should reject non-OAuth tokens."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-api03-regular"])
assert result.exit_code == 0
assert "Invalid token" in result.output
+82
View File
@@ -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"
+65
View File
@@ -0,0 +1,65 @@
"""Test OAuth store integration with config loading."""
import json
import pytest
import tempfile
from pathlib import Path
from nanobot.config.loader import load_config
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
def test_oauth_token_injected_into_config(tmp_path, monkeypatch):
"""OAuth token from store should be injected into provider api_key."""
# Create a minimal config file (no api key set)
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"agents": {"defaults": {"model": "anthropic/claude-opus-4-5"}},
"providers": {"anthropic": {"apiKey": ""}}
}))
# Save OAuth credentials
store = OAuthStore(tmp_path)
creds = OAuthCredentials(access_token="sk-ant-oat01-test-inject")
store.save("anthropic", creds)
# Monkeypatch get_config_path to use our tmp dir
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
# Monkeypatch the OAuth store path
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
config = load_config(config_path)
assert config.providers.anthropic.api_key == "sk-ant-oat01-test-inject"
def test_config_without_oauth_unchanged(tmp_path, monkeypatch):
"""Config without OAuth store should load normally."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"providers": {"anthropic": {"apiKey": "sk-ant-api03-regular"}}
}))
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path / "nonexistent")
config = load_config(config_path)
assert config.providers.anthropic.api_key == "sk-ant-api03-regular"
def test_oauth_does_not_overwrite_existing_key(tmp_path, monkeypatch):
"""If user already has an API key, OAuth should still override (OAuth takes priority)."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"providers": {"anthropic": {"apiKey": "sk-ant-api03-existing"}}
}))
store = OAuthStore(tmp_path)
creds = OAuthCredentials(access_token="sk-ant-oat01-oauth-wins")
store.save("anthropic", creds)
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
config = load_config(config_path)
# OAuth token takes priority over existing API key
assert config.providers.anthropic.api_key == "sk-ant-oat01-oauth-wins"
+116
View File
@@ -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"
+95
View File
@@ -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}
+146
View File
@@ -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"
+37
View File
@@ -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
+24
View File
@@ -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
+128
View File
@@ -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"
+176
View File
@@ -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"
+110
View File
@@ -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"]
+25
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
"""Security tests for MemoryTool20250818."""
import pytest
from pathlib import Path
from nanobot.agent.tools.anthropic import MemoryTool20250818
@pytest.fixture
def temp_workspace(tmp_path):
"""Create temporary workspace."""
return tmp_path
@pytest.fixture
def memory_tool(temp_workspace):
"""Create MemoryTool instance."""
return MemoryTool20250818(workspace=temp_workspace)
class TestPathSecurity:
"""Test path validation security."""
def test_validate_path_valid_root(self, memory_tool):
"""Test that /memories is valid."""
result = memory_tool._validate_memory_path("/memories")
assert result == memory_tool.memories_dir
def test_validate_path_valid_file(self, memory_tool):
"""Test that /memories/notes.txt is valid."""
result = memory_tool._validate_memory_path("/memories/notes.txt")
assert result == memory_tool.memories_dir / "notes.txt"
def test_validate_path_valid_nested(self, memory_tool):
"""Test that /memories/project/status.xml is valid."""
result = memory_tool._validate_memory_path("/memories/project/status.xml")
assert result == memory_tool.memories_dir / "project" / "status.xml"
def test_validate_path_rejects_parent_traversal(self, memory_tool):
"""Test that ../ is rejected."""
with pytest.raises(ValueError, match="escapes /memories directory"):
memory_tool._validate_memory_path("/memories/../config.json")
def test_validate_path_rejects_double_parent_traversal(self, memory_tool):
"""Test that ../../ is rejected."""
with pytest.raises(ValueError, match="escapes /memories directory"):
memory_tool._validate_memory_path("/memories/../../etc/passwd")
def test_validate_path_rejects_absolute_path(self, memory_tool):
"""Test that absolute paths are rejected."""
with pytest.raises(ValueError, match="must start with /memories"):
memory_tool._validate_memory_path("/etc/passwd")
def test_validate_path_rejects_workspace_path(self, memory_tool):
"""Test that /workspace paths are rejected."""
with pytest.raises(ValueError, match="must start with /memories"):
memory_tool._validate_memory_path("/workspace/data.txt")
def test_validate_path_rejects_relative_path(self, memory_tool):
"""Test that relative paths are rejected."""
with pytest.raises(ValueError, match="must start with /memories"):
memory_tool._validate_memory_path("notes.txt")
def test_validate_path_url_encoded_is_safe(self, memory_tool):
"""Test that URL-encoded paths are safe (not decoded by pathlib)."""
# Python's pathlib treats %2e%2e as literal characters, not as ..
# So this is actually safe - it creates a subdirectory named "%2e%2e"
attack_path = "/memories/%2e%2e/config.json"
result = memory_tool._validate_memory_path(attack_path)
# This should resolve to memories/%2e%2e/config.json (literal characters)
assert result == memory_tool.memories_dir / "%2e%2e" / "config.json"
+239
View File
@@ -0,0 +1,239 @@
"""Tests for MemoryTool20250818."""
import pytest
from pathlib import Path
from nanobot.agent.tools.anthropic import MemoryTool20250818
from nanobot.agent.tools.anthropic.base import CLIResult
@pytest.fixture
def temp_workspace(tmp_path):
"""Create temporary workspace."""
return tmp_path
@pytest.fixture
def memory_tool(temp_workspace):
"""Create MemoryTool instance."""
return MemoryTool20250818(workspace=temp_workspace)
def test_memory_tool_initialization(memory_tool, temp_workspace):
"""Test that MemoryTool initializes correctly."""
assert memory_tool.api_type == "memory_20250818"
assert memory_tool.name == "memory"
assert memory_tool.beta_flag == "context-management-2025-06-27"
assert (temp_workspace / "memories").exists()
def test_memory_tool_to_params(memory_tool):
"""Test that to_params returns correct format."""
params = memory_tool.to_params()
assert params == {
"type": "memory_20250818",
"name": "memory"
}
@pytest.mark.asyncio
async def test_view_file(memory_tool, temp_workspace):
"""Test viewing a file with line numbers."""
# Create test file
test_file = temp_workspace / "memories" / "notes.txt"
test_file.write_text("Line 1\nLine 2\nLine 3\n")
result = await memory_tool(command="view", path="/memories/notes.txt")
assert result.exit_code == 0
assert result.error == ""
assert "Here's the content of /memories/notes.txt with line numbers:" in result.output
assert " 1\tLine 1" in result.output
assert " 2\tLine 2" in result.output
assert " 3\tLine 3" in result.output
@pytest.mark.asyncio
async def test_view_file_with_range(memory_tool, temp_workspace):
"""Test viewing a file with line range."""
# Create test file with 10 lines
test_file = temp_workspace / "memories" / "test.txt"
test_file.write_text("\n".join([f"Line {i}" for i in range(1, 11)]))
result = await memory_tool(command="view", path="/memories/test.txt", view_range=[3, 5])
assert result.exit_code == 0
assert " 3\tLine 3" in result.output
assert " 4\tLine 4" in result.output
assert " 5\tLine 5" in result.output
assert "Line 1" not in result.output
assert "Line 10" not in result.output
@pytest.mark.asyncio
async def test_view_file_not_exists(memory_tool):
"""Test viewing a nonexistent file."""
result = await memory_tool(command="view", path="/memories/nonexistent.txt")
assert result.exit_code == 1
assert result.output == ""
assert "The path /memories/nonexistent.txt does not exist" in result.error
@pytest.mark.asyncio
async def test_view_directory(memory_tool, temp_workspace):
"""Test viewing a directory listing."""
# Create test directory structure
memories = temp_workspace / "memories"
(memories / "notes.txt").write_text("content")
(memories / "project").mkdir()
(memories / "project" / "status.xml").write_text("<status>ok</status>")
(memories / ".hidden").write_text("hidden") # Should be excluded
result = await memory_tool(command="view", path="/memories")
assert result.exit_code == 0
assert result.error == ""
assert "Here're the files and directories up to 2 levels deep in /memories" in result.output
assert "/memories" in result.output
assert "/memories/notes.txt" in result.output
assert "/memories/project" in result.output
assert "/memories/project/status.xml" in result.output
assert ".hidden" not in result.output # Hidden files excluded
@pytest.mark.asyncio
async def test_view_empty_directory(memory_tool):
"""Test viewing an empty directory."""
result = await memory_tool(command="view", path="/memories")
assert result.exit_code == 0
assert "/memories" in result.output
@pytest.mark.asyncio
async def test_create_file(memory_tool, temp_workspace):
"""Test creating a new file."""
result = await memory_tool(
command="create",
path="/memories/notes.txt",
file_text="My notes\nLine 2\n"
)
assert result.exit_code == 0
assert result.error == ""
assert "File created successfully at: /memories/notes.txt" in result.output
# Verify file was created
created_file = temp_workspace / "memories" / "notes.txt"
assert created_file.exists()
assert created_file.read_text() == "My notes\nLine 2\n"
@pytest.mark.asyncio
async def test_create_file_nested_directory(memory_tool, temp_workspace):
"""Test creating a file in a nested directory (auto-creates parent dirs)."""
result = await memory_tool(
command="create",
path="/memories/project/status.xml",
file_text="<status>ok</status>"
)
assert result.exit_code == 0
assert "File created successfully at: /memories/project/status.xml" in result.output
# Verify file and parent directory were created
created_file = temp_workspace / "memories" / "project" / "status.xml"
assert created_file.exists()
assert created_file.read_text() == "<status>ok</status>"
@pytest.mark.asyncio
async def test_create_file_already_exists(memory_tool, temp_workspace):
"""Test creating a file that already exists."""
# Create file first
existing = temp_workspace / "memories" / "existing.txt"
existing.write_text("existing content")
result = await memory_tool(
command="create",
path="/memories/existing.txt",
file_text="new content"
)
assert result.exit_code == 1
assert result.output == ""
assert "Error: File /memories/existing.txt already exists" in result.error
# Verify original content unchanged
assert existing.read_text() == "existing content"
@pytest.mark.asyncio
async def test_create_file_missing_text(memory_tool):
"""Test creating a file without file_text parameter."""
result = await memory_tool(
command="create",
path="/memories/notes.txt"
)
assert result.exit_code == 1
assert result.output == ""
assert "Error: file_text is required for create command" in result.error
@pytest.mark.asyncio
async def test_str_replace_success(memory_tool, temp_workspace):
"""Test replacing unique string in a file."""
test_file = temp_workspace / "memories" / "config.txt"
test_file.write_text("color: blue\nsize: large\n")
result = await memory_tool(
command="str_replace",
path="/memories/config.txt",
old_str="blue",
new_str="green"
)
assert result.exit_code == 0
assert result.error == ""
assert "The memory file has been edited." in result.output
# Verify file was modified
assert test_file.read_text() == "color: green\nsize: large\n"
@pytest.mark.asyncio
async def test_str_replace_not_found(memory_tool, temp_workspace):
"""Test replacing string that doesn't exist."""
test_file = temp_workspace / "memories" / "config.txt"
test_file.write_text("color: blue\n")
result = await memory_tool(
command="str_replace",
path="/memories/config.txt",
old_str="red",
new_str="green"
)
assert result.exit_code == 1
assert result.output == ""
assert "No replacement was performed, old_str `red` did not appear verbatim" in result.error
@pytest.mark.asyncio
async def test_str_replace_duplicate(memory_tool, temp_workspace):
"""Test replacing string that appears multiple times."""
test_file = temp_workspace / "memories" / "config.txt"
test_file.write_text("color: blue\nbackground: blue\n")
result = await memory_tool(
command="str_replace",
path="/memories/config.txt",
old_str="blue",
new_str="green"
)
assert result.exit_code == 1
assert result.output == ""
assert "Multiple occurrences of old_str `blue`" in result.error
assert "Please ensure it is unique" in result.error
+57
View File
@@ -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)
+48
View File
@@ -0,0 +1,48 @@
"""Test OAuth configuration schema."""
import pytest
from nanobot.config.schema import ProviderConfig, OAuthCredentials
def test_provider_config_has_oauth_fields():
"""ProviderConfig should have oauth_credentials field."""
config = ProviderConfig(api_key="test")
assert hasattr(config, "oauth_credentials")
assert config.oauth_credentials is None
def test_oauth_credentials_model():
"""OAuthCredentials should store token, refresh, expiry."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
refresh_token="rt_xxx",
expires_at=1234567890,
token_type="oauth"
)
assert creds.access_token.startswith("sk-ant-oat")
assert creds.is_oauth_token is True
def test_oauth_credentials_expiry_check():
"""OAuthCredentials should detect expired tokens."""
import time
expired = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=int(time.time()) - 3600 # 1 hour ago
)
assert expired.is_expired is True
valid = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=int(time.time()) + 3600 # 1 hour from now
)
assert valid.is_expired is False
def test_oauth_credentials_no_expiry():
"""Setup tokens with expires_at=0 should never be expired."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=0 # No expiry (setup-token)
)
assert creds.is_expired is False
assert creds.expires_soon is False
+55
View File
@@ -0,0 +1,55 @@
"""Test OAuth credential storage."""
import pytest
import tempfile
from pathlib import Path
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
@pytest.fixture
def temp_store():
"""Create store with temp directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield OAuthStore(Path(tmpdir) / ".nanobot")
def test_save_and_load_credentials(temp_store):
"""Should save and load OAuth credentials."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
refresh_token="rt_xxx",
expires_at=1234567890
)
temp_store.save("anthropic", creds)
loaded = temp_store.load("anthropic")
assert loaded is not None
assert loaded.access_token == creds.access_token
assert loaded.refresh_token == creds.refresh_token
def test_load_nonexistent_returns_none(temp_store):
"""Should return None for missing credentials."""
assert temp_store.load("nonexistent") is None
def test_delete_credentials(temp_store):
"""Should delete saved credentials."""
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
temp_store.save("anthropic", creds)
assert temp_store.delete("anthropic") is True
assert temp_store.load("anthropic") is None
def test_delete_nonexistent_returns_false(temp_store):
"""Should return False when deleting missing credentials."""
assert temp_store.delete("nonexistent") is False
def test_file_permissions(temp_store):
"""Credentials file should have restricted permissions."""
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
temp_store.save("anthropic", creds)
perms = oct(temp_store.file_path.stat().st_mode)[-3:]
assert perms == "600"
+28
View File
@@ -0,0 +1,28 @@
"""Test OAuth utility functions."""
import pytest
from nanobot.providers.oauth_utils import is_oauth_token, get_auth_headers
def test_is_oauth_token_detects_oat():
"""Should detect sk-ant-oat tokens as OAuth."""
assert is_oauth_token("sk-ant-oat01-buSdhCH2XEkebW7ZQZTvGqH5EwAFh4u52LrdJhAP") is True
assert is_oauth_token("sk-ant-api03-regularkey") is False
assert is_oauth_token("") is False
assert is_oauth_token(None) is False
def test_get_auth_headers_oauth():
"""OAuth tokens should use Authorization: Bearer."""
headers = get_auth_headers("sk-ant-oat01-xxx", is_oauth=True)
assert "Authorization" in headers
assert headers["Authorization"] == "Bearer sk-ant-oat01-xxx"
assert "x-api-key" not in headers
assert headers["anthropic-beta"] == "claude-code-20250219,oauth-2025-04-20"
def test_get_auth_headers_api_key():
"""Regular API keys should use x-api-key."""
headers = get_auth_headers("sk-ant-api03-xxx", is_oauth=False)
assert "x-api-key" in headers
assert headers["x-api-key"] == "sk-ant-api03-xxx"
assert "Authorization" not in headers
+24
View File
@@ -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"
+32
View File
@@ -0,0 +1,32 @@
"""Test provider factory with OAuth support."""
import pytest
from nanobot.providers import create_provider
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
def test_create_provider_oauth_token():
"""OAuth tokens should create AnthropicOAuthProvider."""
provider = create_provider(
api_key="sk-ant-oat01-test-token",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, AnthropicOAuthProvider)
def test_create_provider_regular_key():
"""Regular API keys should create LiteLLMProvider."""
provider = create_provider(
api_key="sk-ant-api03-regular-key",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, LiteLLMProvider)
def test_create_provider_openrouter():
"""OpenRouter keys should create LiteLLMProvider."""
provider = create_provider(
api_key="sk-or-v1-xxx",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, LiteLLMProvider)
+94
View File
@@ -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()
+57
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
"""Test OAuth detection in provider registry."""
import pytest
from nanobot.providers.registry import should_use_oauth_provider
def test_should_use_oauth_for_oat_token():
"""OAuth provider should be used for sk-ant-oat tokens."""
assert should_use_oauth_provider("sk-ant-oat01-xxx", "anthropic/claude-opus-4-5") is True
assert should_use_oauth_provider("sk-ant-oat01-xxx", "claude-sonnet-4") is True
def test_should_not_use_oauth_for_regular_key():
"""Regular API keys should not use OAuth provider."""
assert should_use_oauth_provider("sk-ant-api03-xxx", "claude-opus-4-5") is False
assert should_use_oauth_provider("sk-or-v1-xxx", "anthropic/claude-opus-4-5") is False
def test_should_not_use_oauth_for_non_anthropic():
"""Non-Anthropic models should not use OAuth provider."""
assert should_use_oauth_provider("sk-ant-oat01-xxx", "gpt-4") is False
assert should_use_oauth_provider("sk-ant-oat01-xxx", "deepseek/deepseek-chat") is False
+101
View File
@@ -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
+170
View File
@@ -0,0 +1,170 @@
"""Tests for Telegram message chunking.
Per design doc: messages >4096 chars should split at sentence boundaries.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from nanobot.bus.events import OutboundMessage
from nanobot.channels.telegram import TelegramChannel
@pytest.mark.asyncio
async def test_short_message_not_chunked():
"""Messages under 4096 chars should send as single message."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the bot
sent_messages = []
class MockBot:
async def send_message(self, chat_id, text, parse_mode=None):
sent_messages.append({"chat_id": chat_id, "text": text})
class MockApp:
bot = MockBot()
channel._app = MockApp()
# Short message
msg = OutboundMessage(
channel="telegram",
chat_id="123",
content="Short message."
)
await channel.send(msg)
# Should send exactly 1 message
assert len(sent_messages) == 1
assert sent_messages[0]["text"] == "Short message."
@pytest.mark.asyncio
async def test_long_message_splits_at_sentences():
"""Messages >4096 chars should split at sentence boundaries."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the bot
sent_messages = []
class MockBot:
async def send_message(self, chat_id, text, parse_mode=None):
sent_messages.append({"chat_id": chat_id, "text": text})
class MockApp:
bot = MockBot()
channel._app = MockApp()
# Create a message longer than 4096 chars with clear sentence boundaries
# Each sentence is 200 chars, need 21+ sentences to exceed 4096
sentence = "A" * 195 + "end. " # 200 chars including "end. "
long_content = sentence * 25 # 5000 chars total
msg = OutboundMessage(
channel="telegram",
chat_id="123",
content=long_content
)
await channel.send(msg)
# Should split into multiple messages
assert len(sent_messages) > 1
# Each message should be under 4096 chars
for sent in sent_messages:
assert len(sent["text"]) <= 4096
# All messages combined should equal original (with whitespace trimming)
combined = "".join(sent["text"] for sent in sent_messages)
assert combined.replace(" ", "") == long_content.replace(" ", "")
@pytest.mark.asyncio
async def test_message_at_exactly_4000_chars():
"""Message at exactly 4000 chars should not chunk (safer limit)."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the bot
sent_messages = []
class MockBot:
async def send_message(self, chat_id, text, parse_mode=None):
sent_messages.append({"chat_id": chat_id, "text": text})
class MockApp:
bot = MockBot()
channel._app = MockApp()
# Exactly 4000 chars (upstream uses 4000 as safer limit vs 4096)
content = "A" * 4000
msg = OutboundMessage(
channel="telegram",
chat_id="123",
content=content
)
await channel.send(msg)
# Should send exactly 1 message
assert len(sent_messages) == 1
@pytest.mark.asyncio
async def test_message_preserves_sentence_boundaries():
"""Chunks should split at sentence endings, not mid-sentence."""
config = MagicMock()
config.token = "test-token"
bus = MagicMock()
channel = TelegramChannel(config, bus)
# Mock the bot
sent_messages = []
class MockBot:
async def send_message(self, chat_id, text, parse_mode=None):
sent_messages.append({"chat_id": chat_id, "text": text})
class MockApp:
bot = MockBot()
channel._app = MockApp()
# Create content with clear sentence markers
# First part: just under 4096 chars
part1 = "First sentence. " * 250 # ~4000 chars
part2 = "Second sentence. "
content = part1 + part2
msg = OutboundMessage(
channel="telegram",
chat_id="123",
content=content
)
await channel.send(msg)
# Verify chunks don't break mid-sentence
for sent in sent_messages:
text = sent["text"].strip()
# Each chunk should end with sentence punctuation
if text:
assert text[-1] in ".!?"
+263
View File
@@ -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"]
+65
View File
@@ -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()
+505
View File
@@ -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"
Generated
+2722
View File
File diff suppressed because it is too large Load Diff