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
Xubin RenandGitHub a4d95fd064 Merge PR #1293 to generate short alphanumeric tool_call_id for Mistral compatibility
fix: generate short alphanumeric tool_call_id for Mistral compatibility
2026-02-28 00:15:52 +08:00
Re-bin 1fe94898f6 fix: generate short alphanumeric tool_call_id for Mistral compatibility 2026-02-27 16:13:26 +00:00
Xubin RenandGitHub ef09add825 Merge PR #1278 to guide llm grep using timestamp
Fix(prompt): guide llm grep using timestamp
2026-02-27 23:42:05 +08:00
aiguozhi123456 db4185c8b7 Add timestamp format hint for HISTORY.md grep searching 2026-02-27 11:11:42 +00:00
Xubin RenandGitHub e86cfcde22 Merge PR #1200 to update heartbeat tests to match two-phase tool-call architecture
fix: update heartbeat tests to match two-phase tool-call architecture
2026-02-27 18:10:35 +08:00
Re-bin fdd2c25aed Merge PR #1222: fix runtime context leaking into session history 2026-02-27 10:07:22 +00:00
Re-bin bc558d0592 refactor: merge user-role branches in _save_turn 2026-02-27 10:07:22 +00:00
Re-bin 6bdb590028 Merge remote-tracking branch 'origin/main' into pr-1222 2026-02-27 09:57:45 +00:00
Re-bin a6aa5fbd7c Merge PR #1239: register Matrix channel in manager and schema 2026-02-27 09:53:31 +00:00
Re-bin 12f3365103 fix: remove duplicate import, tidy MatrixConfig comments 2026-02-27 09:53:31 +00:00
Re-bin 2d33371366 Merge remote-tracking branch 'origin/main' into pr-1239 2026-02-27 09:51:33 +00:00
Re-bin 858a62dd9b refactor: slim down helpers.py — remove dead code, compress docstrings 2026-02-27 09:50:12 +00:00
Re-bin 21e9644944 Merge PR #1253: auto-sync workspace templates on startup 2026-02-27 09:46:57 +00:00
Re-bin d5808bf586 refactor: streamline workspace template sync 2026-02-27 09:46:57 +00:00
Re-bin e260219ce6 Merge remote-tracking branch 'origin/main' into pr-1253 2026-02-27 09:41:13 +00:00
Re-bin b7561848e1 Merge PR #1257: feat(feishu): make reaction emoji configurable 2026-02-27 09:32:20 +00:00
Re-bin 969b15dbce Merge remote-tracking branch 'origin/main' into pr-1257 2026-02-27 09:31:12 +00:00
Re-bin 32ecfd32f3 Merge PR #1258: fix Telegram media-group aggregation 2026-02-27 09:30:01 +00:00
Re-bin aa2987be3e refactor: streamline Telegram media-group buffering 2026-02-27 09:30:01 +00:00
Tanish Rajput 568a54ae3e Initialize Matrix channel in ChannelManager when enabled in config 2026-02-27 11:39:01 +05:30
Kim a3e0543eae chore(telegram): keep media-group fix without unrelated formatting changes 2026-02-27 12:16:51 +08:00
Kim aa774733ea fix(telegram): aggregate media-group images into a single inbound turn 2026-02-27 12:08:48 +08:00
kimkitsuragi26 6641bad337 feat(feishu): make reaction emoji configurable
Replace hardcoded THUMBSUP with configurable react_emoji field
in FeishuConfig, consistent with SlackConfig.react_emoji pattern.

Default remains THUMBSUP for backward compatibility.
2026-02-27 11:45:44 +08:00
Re-bin cab901b2fb Merge PR #1228: fix(web): use self.api_key instead of undefined api_key 2026-02-27 02:44:19 +00:00
Re-bin b24df8afeb Merge remote-tracking branch 'origin/main' into pr-1228 2026-02-27 02:43:37 +00:00
Re-bin ec8dee802c refactor: simplify message tool suppress and inline consolidation locks 2026-02-27 02:39:38 +00:00
Hon Jia Xuan cb999ae826 feat: implement automatic workspace template synchronization 2026-02-27 10:39:05 +08:00
Re-bin c3a0c7c9eb Merge PR #1206: fix message tool suppress for cross-channel sends 2026-02-27 02:27:18 +00:00
Re-bin 29e6709e26 refactor: simplify message tool suppress — bool check instead of target tracking 2026-02-27 02:27:18 +00:00
Re-bin ac1c40db91 Merge remote-tracking branch 'origin/main' into pr-1206 2026-02-27 02:17:04 +00:00
Yongfeng Huang 7a3788fee9 fix(web): use self.api_key instead of undefined api_key
Made-with: Cursor
2026-02-26 15:43:04 +08:00
Kim 286e67ddef style(agent): remove inline comment in runtime-context history filter 2026-02-26 14:21:44 +08:00
Kim 45ae410f05 fix(agent): do not persist runtime context metadata in session history 2026-02-26 14:12:37 +08:00
Re-bin cc425102ac docs: update Matrix channel guideline and schema 2026-02-26 03:08:00 +00:00
Re-bin a1e930d942 Merge PR #420: feat: add Matrix (Element) channel 2026-02-26 03:04:13 +00:00
Re-bin 988a85d8de refactor: optimize matrix channel — optional deps, trim comments, simplify methods 2026-02-26 03:04:01 +00:00
Re-bin 84f2f3c316 Merge remote-tracking branch 'origin/main' into pr-420 2026-02-26 02:48:21 +00:00
Re-bin a77add9d8c Merge PR #1191: fix base64 images stored in session history causing context overflow 2026-02-26 02:43:50 +00:00
Re-bin a1440cf4cb refactor: inline base64 image stripping in _save_turn 2026-02-26 02:43:45 +00:00
Re-bin 0a9bb1d8df Merge remote-tracking branch 'origin/main' into pr-1191 2026-02-26 02:39:53 +00:00
Re-bin 4eb44cfb5c Merge PR #1198: fix assistant messages without tool calls not being saved to session 2026-02-26 02:33:38 +00:00
Re-bin 3902e31165 refactor: drop redundant tool_calls=None in final assistant message 2026-02-26 02:33:38 +00:00
Re-bin 23b9880478 Merge remote-tracking branch 'origin/main' into pr-1198 2026-02-26 02:29:45 +00:00
Re-bin 7e1a08d33c docs: add provider option to Quick Start config example 2026-02-26 02:23:07 +00:00
Xubin RenandGitHub cffba8d0be Merge PR #1214 to support explicit provider selection in config
feat: support explicit provider selection in config
2026-02-26 10:17:08 +08:00
Re-bin 65477e4bf3 feat: support explicit provider selection in config 2026-02-26 02:15:42 +00:00
Re-bin 39ab89cbd1 Merge PR #1180: feat: /stop command with task-based dispatch 2026-02-25 17:04:19 +00:00
Re-bin cdbede2fa8 refactor: simplify /stop dispatch, inline commands, trim verbose docstrings 2026-02-25 17:04:08 +00:00
chengyongru fafd8d4eb8 fix(agent): only suppress final reply when message tool sends to same target
A refactoring in commit 132807a introduced a regression where the final
response was silently discarded whenever the message tool was used,
regardless of the target. This restored the original logic from PR #832
that only suppresses the final reply when the message tool sends to the
same (channel, chat_id) as the original message.

Changes:
- message.py: Replace _sent_in_turn: bool with _turn_sends: list[tuple]
  to track actual send targets, add get_turn_sends() method
- loop.py: Check if (msg.channel, msg.chat_id) is in sent_targets before
  suppressing final reply. Also move the "Response to" log after the
  suppress check to avoid misleading logs.
- Add unit tests for the suppress logic

This ensures:
- Email sent via message tool → Feishu still gets confirmation
- Message tool sends to same Feishu chat → No duplicate (suppressed)
2026-02-26 00:32:48 +08:00
Re-bin 149f26af32 Merge branch 'main' into pr-1180 2026-02-25 16:16:18 +00:00
Re-bin becb0a4b87 Merge PR #1126: feat: add untrusted runtime context layer for stable prompt prefix 2026-02-25 16:13:48 +00:00
Re-bin d55a850357 refactor: simplify runtime context injection — drop JSON/dedup, keep untrusted tag 2026-02-25 16:13:48 +00:00
Re-bin b19c729eee Merge branch 'main' into pr-1126 2026-02-25 16:04:06 +00:00
Re-bin 3f41e39c8d Merge PR #1083: feat(exec): add path_append config to extend PATH for subprocess 2026-02-25 15:57:50 +00:00
Re-bin 9eca7f339e docs: shorten pathAppend description in config table 2026-02-25 15:57:50 +00:00
Re-bin e1a2ef4f29 Merge branch 'main' into pr-1083 2026-02-25 15:50:00 +00:00
Elliot LeeandClaude Opus 4.6 19a5efa89e fix: update heartbeat tests to match two-phase tool-call architecture
HeartbeatService was refactored from free-text HEARTBEAT_OK token
matching to a structured two-phase design (LLM tool call for
skip/run decision, then execution). The tests still used the old
on_heartbeat callback constructor and HEARTBEAT_OK_TOKEN import.

- Remove obsolete test_heartbeat_ok_detection test
- Update test_start_is_idempotent to use new provider+model constructor
- Add tests for _decide() skip path, trigger_now() run/skip paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:47:52 -08:00
VITOHJL f2e0847d64 Fix assistant messages without tool calls not being saved to session 2026-02-25 23:27:41 +08:00
dxtime 6aed4265b7 Fix: The base64 images are stored in the session history, causing context overflow. 2026-02-25 20:58:59 +08:00
coldxiangyu 4768b9a09d fix: parallel subagent cancellation + register task before lock
- cancel_by_session: use asyncio.gather for parallel cancellation
  instead of sequential await per task
- _dispatch: register in _active_tasks before acquiring lock so /stop
  can find queued tasks (synced from #1179)
2026-02-25 18:21:46 +08:00
coldxiangyu 2466b8b843 feat: /stop cancels spawned subagents via session tracking
- SubagentManager tracks _session_tasks: session_key -> {task_id, ...}
- cancel_by_session() cancels all subagents for a session
- SpawnTool passes session_key through to SubagentManager
- /stop response reports subagent cancellation count
- Cleanup callback removes from both _running_tasks and _session_tasks

Builds on #1179
2026-02-25 17:53:54 +08:00
coldxiangyu 3c12efa728 feat: extensible command system + task-based dispatch with /stop
- Add commands.py with CommandDef registry, parse_command(), get_help_text()
- Refactor run() to dispatch messages as asyncio tasks (non-blocking)
- /stop is an 'immediate' command: handled inline, cancels active task
- Global processing lock serializes message handling (safe for shared state)
- _pending_tasks set prevents GC of dispatched tasks before lock acquisition
- _dispatch() registers/clears active tasks, catches CancelledError gracefully
- /help now auto-generated from COMMANDS registry

Closes #849
2026-02-25 17:51:00 +08:00
aiguozhi123456 a50a2c6868 fix(docs): clarify platform-specific path separator 2026-02-25 01:53:04 +00:00
aiguozhi123456 e959b13926 docs: add pathAppend option to exec config docs 2026-02-25 01:49:56 +00:00
Re-bin 9e806d7159 Merge PR #1074: fix: preserve reasoning_content in message sanitization for thinking models 2026-02-25 00:38:51 +00:00
Re-bin 8fffee124b Merge branch 'main' into pr-1074 2026-02-25 00:38:20 +00:00
rickthemad4 87a2084ee2 feat: add untrusted runtime context layer for stable prompt prefix 2026-02-24 16:38:29 +00:00
Re-bin a3963bfba3 docs: update v0.1.4.post2 release news 2026-02-24 16:35:50 +00:00
Re-bin 637c200dee docs: update v0.1.4.post2 release news 2026-02-24 16:34:22 +00:00
Re-bin 17de3699ab chore: bump version to 0.1.4.post2 2026-02-24 16:24:47 +00:00
Re-bin abc7b0aeb2 Merge PR #1107: fix(slack): post-process slackify_markdown output to catch leftover artifacts 2026-02-24 16:20:28 +00:00
Re-bin 96e1730af5 style: simplify _fixup_mrkdwn and trim docstring in SlackChannel 2026-02-24 16:20:28 +00:00
Re-bin a3f7cce416 Merge branch 'main' into pr-1107 2026-02-24 16:19:14 +00:00
Re-bin f223a4c5a3 Merge PR #1115: fix: stabilize system prompt for better cache reuse 2026-02-24 16:15:21 +00:00
Re-bin f294e9d065 refactor: merge runtime context helpers and move imports to top 2026-02-24 16:15:21 +00:00
rickthemad4 56b9b33c6d fix: stabilize system prompt for better cache reuse 2026-02-24 14:18:50 +00:00
Re-bin a818fff8fa chore: trim verbose docstrings 2026-02-24 13:47:17 +00:00
Re-bin a54b0853f0 Merge PR #1071: refactor(web): resolve api_key via property instead of inline 2026-02-24 13:42:35 +00:00
Re-bin 4b9ffea3fc merge origin/main into pr-1071, adopt @property api_key pattern 2026-02-24 13:41:49 +00:00
nanobot-agentandCursor 81b669b36e fix(slack): post-process slackify_markdown output to catch leftover artifacts
The slackify_markdown library (markdown-it) fails to convert **bold** when
the closing ** is immediately followed by non-space text (e.g. **Status:**OK).
This is a very common LLM output pattern that results in raw ** showing up
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** → *bold* (Slack mrkdwn)
- Converts leftover ## headers → *bold* (safety net)
- Fixes over-escaped &amp; in bare URLs
- Protects code fences and inline code from being mangled

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:44:17 +00:00
nanobot-agentandCursor 8686f060d9 fix(slack): add post-processing to fix mrkdwn conversion edge cases
The slackify_markdown library misses several patterns that LLMs commonly
produce, causing raw Markdown symbols (**bold**, ##headers) to appear
in Slack messages.

Add _fixup_mrkdwn() post-processor that:
- Converts leftover **bold** patterns (e.g. **Status:**OK where closing
  ** is adjacent to non-space chars)
- Fixes &amp; over-escaping in bare URLs
- Protects code blocks from false-positive fixups

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 12:43:21 +00:00
aiguozhi123456 07ae82583b fix: pass path_append from config to ExecTool 2026-02-24 12:31:18 +00:00
Re-bin 0e4dba8d19 Merge PR #1062: fix(mcp): disable httpx default timeout for HTTP transport 2026-02-24 12:15:33 +00:00
Re-bin e080902d61 Merge remote-tracking branch 'origin/main' into pr-1062 2026-02-24 12:14:00 +00:00
aiguozhi123456 7be278517e fix(exec): use empty default and os.pathsep for cross-platform 2026-02-24 12:13:52 +00:00
Re-bin f828a1d5d1 fix(gateway): show actual heartbeat interval in startup log 2026-02-24 12:09:19 +00:00
Re-bin e4888d39f7 Merge PR #1077: fix(email): auto_reply_enabled should not block proactive sends 2026-02-24 12:08:13 +00:00
Re-bin c6b933df4a Merge remote-tracking branch 'origin/main' into pr-1077 2026-02-24 11:38:38 +00:00
Re-bin f514ba02e9 Merge PR #1090: feat(feishu): extract and download images from post messages 2026-02-24 11:32:04 +00:00
Re-bin 04218276ab Merge remote-tracking branch 'origin/main' into pr-1090 2026-02-24 11:31:40 +00:00
Re-bin cd5a8ac03d Merge PR #1061: fix(memory): handle JSON-string tool call arguments from providers 2026-02-24 11:23:10 +00:00
Re-bin d546cbac6e style(memory): use loguru {} formatting in warning 2026-02-24 11:23:10 +00:00
Re-bin b9eb9d4963 Merge remote-tracking branch 'origin/main' into pr-1061 2026-02-24 11:22:01 +00:00
Re-bin abd35b1295 Merge PR #1098: fix(web): resolve API key on each call + improve error message 2026-02-24 11:18:33 +00:00
Re-bin cda3a02f68 style(web): inline api key resolution, remove unnecessary method 2026-02-24 11:18:33 +00:00
Re-bin fdf24e8fd2 Merge branch 'main' into pr-1098 2026-02-24 11:14:37 +00:00
Xubin RenandGitHub 8d1eec114a Merge PR #1102 to replace HEARTBEAT_OK token with virtual tool-call decision
fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision
2026-02-24 19:07:55 +08:00
Re-bin ec55f77912 fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision 2026-02-24 11:04:56 +00:00
coldxiangyu ef57225974 fix(web): resolve API key on each call + improve error message
- Defer Brave API key resolution to execute() time instead of __init__,
  so env var or config changes take effect without gateway restart
- Improve error message to reference actual config path
  (tools.web.search.apiKey) instead of only mentioning env var

Fixes #1069 (issues 1 and 2 of 3)
2026-02-24 18:19:47 +08:00
xzq.xuandCursor 4f8033627e feat(feishu): support images in post (rich text) messages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 13:42:07 +08:00
aiguozhi123456 abcce1e1db feat(exec): add path_append config to extend PATH for subprocess 2026-02-24 03:18:23 +00:00
chengyongru 91e13d91ac fix(email): allow proactive sends when autoReplyEnabled is false
Previously, `autoReplyEnabled=false` would block ALL email sends,
including proactive emails triggered from other channels (e.g., asking
nanobot on Feishu to send an email).

Now `autoReplyEnabled` only controls automatic replies to incoming
emails, not proactive sends. This allows users to disable auto-replies
while still being able to ask nanobot to send emails on demand.

Changes:
- Check if recipient is in `_last_subject_by_chat` to determine if
  it's a reply
- Only skip sending when it's a reply AND auto_reply_enabled is false
- Add test for proactive send with auto_reply_enabled=false
- Update existing test to verify reply behavior
2026-02-24 04:27:14 +08:00
haosenwang1018 8de2f8d588 fix: preserve reasoning_content in message sanitization for thinking models
_sanitize_messages strips all non-standard keys from messages, including
reasoning_content. Thinking-enabled models like Moonshot Kimi k2.5
require reasoning_content to be present in assistant tool call messages
when thinking mode is on, causing a BadRequestError (#1014).

Add reasoning_content to _ALLOWED_MSG_KEYS so it passes through
sanitization when present.

Fixes #1014
2026-02-24 04:21:55 +08:00
haosenwang1018 eeaad6e0c2 fix: resolve API key at call time so config changes take effect without restart
Previously, WebSearchTool cached the API key in __init__, so keys added
to config.json or env vars after gateway startup were never picked up.
This caused a confusing 'BRAVE_API_KEY not configured' error even after
the key was correctly set (issue #1069).

Changes:
- Store the init-time key separately, resolve via property at each call
- Improve error message to guide users toward the correct fix

Closes #1069
2026-02-24 04:06:22 +08:00
Re-bin 30361c9307 refactor: replace cron usage docs in TOOLS.md with reference to cron skill 2026-02-23 18:28:09 +00:00
dulltackle f8dc6fafa9 **fix(mcp): Remove default timeout for HTTP transport to avoid tool timeout conflicts**
Always provide an explicit httpx client to prevent MCP HTTP transport from inheriting httpx's default 5-second timeout, thereby avoiding conflicts with the upper layer tool's timeout settings.
2026-02-24 01:26:56 +08:00
alairjt 3eeac4e8f8 Fix: handle non-string tool call arguments in memory consolidation
Fixes #1042. When the LLM returns tool call arguments as a dict or
JSON string instead of parsed values, memory consolidation would fail
with "TypeError: data must be str, not dict".

Changes:
- Add type guard in MemoryStore.consolidate() to parse string arguments
  and reject unexpected types gracefully
- Add regression tests covering dict args, string args, and edge cases
2026-02-23 13:59:49 -03:00
Re-bin 2f573e591b fix(session): get_history uses last_consolidated cursor, aligns to user turn 2026-02-23 16:57:08 +00:00
Re-bin 35e3f7ed26 fix(templates): tighten AGENTS.md tool call guidelines to reduce hallucinations 2026-02-23 14:10:43 +00:00
Re-bin c76a8d2e83 Merge PR #1029: fix: break Discord typing loop on persistent HTTP failure 2026-02-23 14:06:36 +00:00
Re-bin 3c2cc3a71c Merge remote-tracking branch 'origin/main' into pr-1029 2026-02-23 14:01:43 +00:00
Re-bin f4b3bbd87c Merge PR #1039: fix(heartbeat): make start idempotent and add tests 2026-02-23 13:59:47 +00:00
Re-bin eae6059889 fix: remove extra blank line 2026-02-23 13:59:47 +00:00
Re-bin 6f4d1c2cdc merge origin/main into pr-1039, adopt HEARTBEAT_OK in-check and on_notify 2026-02-23 13:57:28 +00:00
Xubin RenandGitHub 54e350a496 Merge PR #1054 to deliver agent response to user and fix HEARTBEAT_OK detection
fix(heartbeat): deliver agent response to user and fix HEARTBEAT_OK detection
2026-02-23 21:52:08 +08:00
Re-bin 7671239902 fix(heartbeat): suppress progress messages and deliver agent response to user 2026-02-23 13:45:09 +00:00
Re-bin 2c09f23c02 Merge PR #1048: feat(slack): isolate session context per thread 2026-02-23 13:10:55 +00:00
Re-bin 2b983c708d refactor: pass session_key as explicit param instead of via metadata 2026-02-23 13:10:47 +00:00
Re-bin 0be70b05b1 Merge remote-tracking branch 'origin/main' into pr-1048 2026-02-23 13:04:54 +00:00
Re-bin ea1c4ef025 fix: suppress heartbeat progress messages to external channels 2026-02-23 12:33:29 +00:00
Paul 1f7a81e5ee feat(slack): isolate session context per thread
Each Slack thread now gets its own conversation session instead of
sharing one session per channel. DM sessions are unchanged.

Added as a generic feature to also support if Feishu threads support
is added in the future.
2026-02-23 10:23:55 +00:00
Xubin RenandGitHub b2a1d1208e Merge PR #1046 to improve agent reliability: behavioral constraints, full tool history, error hints
improve agent reliability: behavioral constraints, full tool history, error hints
2026-02-23 17:16:09 +08:00
Re-bin d9462284e1 improve agent reliability: behavioral constraints, full tool history, error hints 2026-02-23 09:13:08 +00:00
Re-bin 491739223d fix: lower default temperature from 0.7 to 0.1 2026-02-23 08:24:53 +00:00
Xubin RenandGitHub e69ff8ac0e Merge pull request #1043 to move workspace/ to nanobot/templates/ for packaging
refactor: move workspace/ to nanobot/templates/ for packaging
2026-02-23 16:11:44 +08:00
Re-bin 577b3d104a refactor: move workspace/ to nanobot/templates/ for packaging 2026-02-23 08:08:01 +00:00
Re-bin f8e8cbee6a Merge PR #1036: fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 07:45:20 +00:00
Re-bin e4376896ed Merge remote-tracking branch 'origin/main' into pr-1036 2026-02-23 07:16:40 +00:00
Re-bin 0fdbd5a037 Merge PR #1000: feat(channels): add send_progress option to control progress message delivery 2026-02-23 07:12:55 +00:00
Re-bin df2c837e25 feat(channels): split send_progress into send_progress + send_tool_hints 2026-02-23 07:12:41 +00:00
Re-bin c20b867497 Merge remote-tracking branch 'origin/main' into pr-1000 2026-02-23 06:12:25 +00:00
yzchen bfdae1b177 fix(heartbeat): make start idempotent and check exact OK token 2026-02-23 13:56:37 +08:00
Re-bin bc32e85c25 fix(memory): trigger consolidation by unconsolidated count, not total 2026-02-23 05:51:44 +00:00
Kim 9025c7088f fix(heartbeat): route heartbeat runs to enabled chat context 2026-02-23 12:28:21 +08:00
Yingwen Luo-LUOYW 31a873ca59 Merge branch 'main' of https://github.com/HKUDS/nanobot 2026-02-23 09:41:56 +08:00
Yingwen Luo-LUOYW 0c412b3728 feat(channels): add send_progress option to control progress message delivery
Add a boolean config option `channels.sendProgress` (default: false) to
control whether progress messages (marked with `_progress` metadata) are
sent to chat channels. When disabled, progress messages are filtered
out in the outbound dispatcher.
2026-02-23 09:41:13 +08:00
Nikolas de Hor 4303026e0d fix: break Discord typing loop on persistent HTTP failure
The typing indicator loop catches all exceptions with bare
except/pass, so a permanent HTTP failure (client closed, auth
error, etc.) causes the loop to spin every 8 seconds doing
nothing until the channel is explicitly stopped.

Log the error and exit the loop instead, letting the task
clean up naturally.
2026-02-22 22:01:16 -03:00
Re-bin 25f0a236fd docs: fix MiniMax API key link 2026-02-22 18:29:09 +00:00
Re-bin c6f670809c Merge PR #949: fix(provider): filter empty text content blocks causing API 400 2026-02-22 18:26:42 +00:00
Re-bin b653183bb0 refactor(providers): move empty content sanitization to base class 2026-02-22 18:26:42 +00:00
Re-bin 2f7835a301 Merge remote-tracking branch 'origin/main' into pr-949 2026-02-22 18:21:47 +00:00
Re-bin 6913d541c8 Merge PR #986: fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 18:16:45 +00:00
Re-bin efe89c9091 fix(feishu): pass msg_type as resource_type and clean up style 2026-02-22 18:16:45 +00:00
Re-bin 3d55c9cd03 Merge remote-tracking branch 'origin/main' into pr-986 2026-02-22 18:13:37 +00:00
Re-bin 4f0930f517 Merge PR #955: fix(providers): normalize empty reasoning_content to None at provider level 2026-02-22 18:11:45 +00:00
Re-bin c8881c5d49 Merge remote-tracking branch 'origin/main' into pr-955 2026-02-22 18:08:43 +00:00
Re-bin e46edf2806 Merge PR #950: fix(mcp): add configurable timeout to MCP tool calls 2026-02-22 18:04:13 +00:00
Re-bin 437ebf4e6e feat(mcp): make tool_timeout configurable per server via config 2026-02-22 18:04:13 +00:00
Re-bin 51f6247aed Merge remote-tracking branch 'origin/main' into pr-950 2026-02-22 17:52:24 +00:00
Re-bin 14ba50c172 Merge PR #968: docs: add systemd user service instructions to README 2026-02-22 17:51:23 +00:00
Re-bin 1aa06ea03d docs: improve Linux Service section in README 2026-02-22 17:51:23 +00:00
Re-bin 12af652d5a Merge remote-tracking branch 'origin/main' into pr-968 2026-02-22 17:48:32 +00:00
Re-bin e322f82f9c Merge PR #962: fix(qq): make start() long-running per base channel contract 2026-02-22 17:35:53 +00:00
Re-bin b53c3d39ed fix(qq): remove dead _bot_task field and fix stop() to close client 2026-02-22 17:35:53 +00:00
Re-bin 9efe95970e Merge branch 'main' into pr-962 2026-02-22 17:24:34 +00:00
Re-bin b13d7f853e fix(agent): make tool hint a fallback when no content in on_progress 2026-02-22 17:17:35 +00:00
Re-bin d5e820df98 Merge PR #881: fix(loop): serialize /new consolidation, track task refs, archive before clear 2026-02-22 17:11:59 +00:00
Re-bin 1cfcc647b7 fix(loop): resolve conflicts with main and improve /new handler 2026-02-22 17:11:59 +00:00
Re-bin 60751909cb Merge PR #959: fix(email): evict oldest half of dedup set instead of clearing entirely 2026-02-22 15:48:49 +00:00
Re-bin 4e8c8cc227 fix(email): fix misleading comment and simplify uid eviction 2026-02-22 15:48:49 +00:00
Re-bin d82c292c99 Merge branch 'main' into pr-959 2026-02-22 15:41:09 +00:00
Re-bin 598f7dafd1 Merge PR #958: fix(session): handle errors in legacy session migration 2026-02-22 15:40:17 +00:00
Re-bin 71de1899e6 fix(session): use logger.exception and move import to top 2026-02-22 15:40:17 +00:00
Re-bin b8a06f8d19 Merge branch 'main' into pr-958 2026-02-22 15:39:09 +00:00
Re-bin b161628ad7 Merge PR #957: fix(slack): add exception handling to socket listener 2026-02-22 15:38:19 +00:00
Re-bin b93b77a485 fix(slack): use logger.exception to capture full traceback 2026-02-22 15:38:19 +00:00
Re-bin c53deecdb1 Merge branch 'main' into pr-957 2026-02-22 15:35:26 +00:00
Re-bin ef64739736 Merge PR #956: fix(security): prevent path traversal bypass via startswith check 2026-02-22 15:34:36 +00:00
Re-bin e0743d6345 Merge branch 'main' into pr-956 2026-02-22 15:33:28 +00:00
FloRa 0d3a2963d0 fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:37:33 +08:00
FloRa 973061b01e fix(feishu): replace file.get with message_resource.get to fix file download permission issue 2026-02-22 17:15:00 +08:00
Xubin RenandGitHub fff6207c6b Merge PR #982 to add DingTalk, QQ, and Email to channels status output
feat(cli): add DingTalk, QQ, and Email to channels status output
2026-02-22 14:57:44 +08:00
TANISH RAJPUTandGitHub 1532f11b45 Merge pull request #7 from Athemis/feat/matrix-improvements
fix(matrix): harmonize units and keep typing indicator during tool calls
2026-02-22 11:47:17 +05:30
Yingwen Luo-LUOYW b323087631 feat(cli): add DingTalk, QQ, and Email to channels status output 2026-02-22 12:42:33 +08:00
Rok Pergarec 3e40600483 docs: add systemd user service instructions to README 2026-02-21 20:55:54 +01:00
Alexander Minges 494fa8966a refactor(matrix): use milliseconds for typing timing constants 2026-02-21 20:45:09 +01:00
Alexander Minges de5104ab2a fix(matrix): keep typing indicator during progress updates 2026-02-21 20:44:51 +01:00
andienguyen-ecoligo 8c55b40b9f fix(qq): make start() long-running per base channel contract
QQ channel's start() created a background task and returned immediately,
violating the base Channel contract which specifies start() should be
"a long-running async task". This caused the gateway to exit prematurely
when QQ was the only enabled channel.

Now directly awaits _run_bot() to stay alive like other channels.

Fixes #894
2026-02-21 12:38:24 -05:00
andienguyen-ecoligo ba66c64750 fix(email): evict oldest half of dedup set instead of clearing entirely
When _processed_uids exceeds 100k entries, the entire set was cleared
with .clear(), allowing all previously seen emails to be re-processed.

Now evicts the oldest 50% of entries, keeping recent UIDs to prevent
duplicate processing while still bounding memory usage.

Fixes #890
2026-02-21 12:36:04 -05:00
andienguyen-ecoligo 54a0f3d038 fix(session): handle errors in legacy session migration
shutil.move() in _load() can fail due to permissions, disk full, or
concurrent access. Without error handling, the exception propagates up
and prevents the session from loading entirely.

Wrap in try/except so migration failures are logged as warnings and the
session falls back to loading from the legacy path on next attempt.

Fixes #863
2026-02-21 12:35:21 -05:00
andienguyen-ecoligo ef96619039 fix(slack): add exception handling to socket listener
_handle_message() in _on_socket_request() had no try/except. If it
throws (bus full, permission error, etc.), the exception propagates up
and crashes the Socket Mode event loop, causing missed messages.

Other channels like Telegram already have explicit error handlers.

Fixes #895
2026-02-21 12:34:50 -05:00
andienguyen-ecoligo 5c9cb3a208 fix(security): prevent path traversal bypass via startswith check
`startswith` string comparison allows bypassing directory restrictions.
For example, `/home/user/workspace_evil` passes the check against
`/home/user/workspace` because the string starts with the allowed path.

Replace with `Path.relative_to()` which correctly validates that the
resolved path is actually inside the allowed directory tree.

Fixes #888
2026-02-21 12:34:14 -05:00
andienguyen-ecoligo de63c31d43 fix(providers): normalize empty reasoning_content to None at provider level
PR #947 fixed the consumer side (context.py) but the root cause is at
the provider level — getattr returns "" (empty string) instead of None
when reasoning_content is empty. This causes DeepSeek API to reject the
request with "Missing reasoning_content field" error.

`"" or None` evaluates to None, preventing empty strings from
propagating downstream.

Fixes #946
2026-02-21 12:30:57 -05:00
Re-bin 0040c62b74 Merge PR #939: Remove redundant tools description from system prompt 2026-02-21 17:07:02 +00:00
Re-bin 13d768cd93 Merge branch 'main' into pr-939 2026-02-21 17:06:05 +00:00
Xubin RenandGitHub 6a9152f0c4 Merge PR #947 to Fix 'Missing reasoning_content field' error for deepseek provider.
fix(context): Fix 'Missing `reasoning_content` field' error for deepseek provider.
2026-02-22 00:47:58 +08:00
Xubin RenandGitHub 9b4273f6a4 Merge PR #951 to change VolcEngine litellm prefix from openai to volcengine
fix: change VolcEngine litellm prefix from openai to volcengine
2026-02-22 00:45:49 +08:00
init-new-world deae84482d fix: change VolcEngine litellm prefix from openai to volcengine 2026-02-22 00:42:41 +08:00
muskliu 6b7d7e2eb8 fix(mcp): add 30s timeout to MCP tool calls to prevent agent hangs 2026-02-22 00:39:53 +08:00
Re-bin edc671a8a3 docs: update format of news section 2026-02-21 16:39:26 +00:00
muskliuandnanobot 83ccdf6186 fix(provider): filter empty text content blocks causing API 400
When MCP tools return empty content, messages may contain empty-string
text blocks. OpenAI-compatible providers reject these with HTTP 400.

Changes:
- Add _prevent_empty_text_blocks() to filter empty text items from
  content lists and handle empty string content
- For assistant messages with tool_calls, set content to None (valid)
- For other messages, replace with '(empty)' placeholder
- Only copy message dict when modification is needed (zero-copy path
  for normal messages)

Co-Authored-By: nanobot <noreply@anthropic.com>
2026-02-22 00:20:00 +08:00
nanobot-bot 01c835aac2 fix(context): Fix 'Missing reasoning_content field' error for deepseek provider. 2026-02-21 23:11:30 +08:00
Re-bin 88ca2e0530 docs: update v.0.1.4.post1 release news 2026-02-21 13:20:55 +00:00
Re-bin af71ccf051 release: v0.1.4.post1 2026-02-21 13:05:14 +00:00
vincentchen b3acd19c7b Remove redundant tools description (because tools information is passed in with each self.provider.chat() call) 2026-02-21 20:28:42 +08:00
Re-bin 9c61e1389c docs: update nanobot news 2026-02-21 08:33:31 +00:00
Re-bin ec4bdb651f docs: update nanobot news 2026-02-21 08:33:02 +00:00
Re-bin f89f8a972c Merge pull request #926: fix(agent): skip empty fallback outbound for non-cli channels 2026-02-21 08:27:54 +00:00
Re-bin 0b30f514b4 style(loop): compact empty outbound message construction 2026-02-21 08:27:49 +00:00
Re-bin 012a5e78e5 Merge branch 'main' into pr-926 2026-02-21 08:21:17 +00:00
Xubin RenandGitHub 4dca2872bf Merge pull request #930 to slim down agent loop
refactor: extract memory consolidation to MemoryStore, slim down agent loop
2026-02-21 16:19:08 +08:00
Re-bin ab026c5131 refactor: extract memory consolidation to MemoryStore, slim down AgentLoop 2026-02-21 08:14:46 +00:00
Re-bin 668dd6e2f5 Merge pull request #866: refactor(memory): use tool call instead of JSON text for memory consolidation 2026-02-21 08:02:03 +00:00
Re-bin 8c15454379 Merge branch 'main' into pr-866 2026-02-21 07:46:25 +00:00
Xubin RenandGitHub 6076f98527 Merge pull request #928 to remove interim text retry, use system prompt constraint instead
refactor(loop): remove interim text retry, use system prompt constraint instead
2026-02-21 15:35:35 +08:00
Re-bin aeb07d3450 refactor(loop): remove interim text retry, use system prompt constraint instead 2026-02-21 07:32:58 +00:00
Re-bin a0820eceee Merge pull request #887: fix(loop): preserve interim content as fallback when retry produces empty response 2026-02-21 07:17:35 +00:00
Re-bin 8bb849470b Merge branch 'main' into pr-887 2026-02-21 07:12:58 +00:00
Alexander Minges c4bee640b8 fix(agent): skip empty fallback outbound for non-cli channels 2026-02-21 07:51:28 +01:00
Re-bin 900604e9ca Merge pull request #921: fix(tools): provide diff hint when edit_file old_text not found 2026-02-21 06:39:14 +00:00
Re-bin 4f5cb7d1e4 style(filesystem): simplify best-match loop 2026-02-21 06:39:04 +00:00
Re-bin 09a45f8993 Merge pull request #921: fix(tools): provide diff hint when edit_file old_text not found 2026-02-21 06:35:14 +00:00
Re-bin e0edb904bd style(filesystem): move difflib import to top level 2026-02-21 06:35:10 +00:00
Re-bin 7bc77c1b41 Merge branch 'main' into pr-921 2026-02-21 06:32:57 +00:00
Re-bin 6f266f1a8a Merge pull request #922: feat(feishu): multimedia download and share card parsing 2026-02-21 06:30:31 +00:00
Re-bin 8125d9b6bc fix(feishu): fix double recursion, English placeholders, top-level Path import 2026-02-21 06:30:26 +00:00
coldxiangyuandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> b9c3f8a5a3 feat(feishu): add share card and interactive message parsing
- Add content extraction for share cards (chat, user, calendar event)
- Add recursive parsing for interactive card elements
- Fix image download API to use GetMessageResourceRequest with message_id
- Handle BytesIO response from message resource API

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-21 14:08:25 +08:00
coldxiangyuandClaude Opus 4.6 98ef57e370 feat(feishu): add multimedia download support for images, audio and files
Add download functionality for multimedia messages in Feishu channel,
enabling agents to process images, audio recordings, and file attachments
sent through Feishu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:56:57 +08:00
themavikandCursor 33396a522a fix(tools): provide detailed error messages in edit_file when old_text not found
Uses difflib to find the best match and shows a helpful diff,
making it easier to debug edit_file failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 23:52:40 -05:00
TANISH RAJPUTandGitHub 2502f68fd8 Merge pull request #6 from Athemis/feat/matrix-improvements
feat(matrix): E2E, typing, markdown/HTML, group policy, inbound+outbound media, thread replies
2026-02-20 23:01:56 +05:30
Alexander Minges fcece3ec62 fix(matrix): match fork/main formatting exactly 2026-02-20 18:17:27 +01:00
Alexander Minges 13561772ad fix(matrix): align with fork/main (docstrings, type annotations, formatting) 2026-02-20 18:15:32 +01:00
Alexander Minges dd61a9143a fix: remove accidental whitespace-only formatting changes from schema.py 2026-02-20 18:11:29 +01:00
Alexander Minges 52d086d46a revert: restore context.py and manager.py to tanishra baseline (out of scope) 2026-02-20 18:08:04 +01:00
Alexander Minges e8a4671565 test: remove message tool media test (message.py changes out of scope) 2026-02-20 18:06:13 +01:00
Alexander Minges 36d650e475 revert: restore message.py to tanishra baseline (out of scope) 2026-02-20 18:05:00 +01:00
Alexander Minges 334078e242 fix(message): apply media path filtering and drop attachment count from return value
Conflict resolution correction: HEAD's message.py retained raw media list and
attachment count in return string, but tests from 3de30bb require stripped/filtered
media_paths and a plain return message. Aligns HEAD behavior with cherry-picked tests.
2026-02-20 18:04:11 +01:00
Alexander MingesandAlexander Minges 705d5738e3 feat(matrix): reply in threads with fallback relations
Propagate Matrix thread metadata from inbound events and attach
m.relates_to
(rel_type=m.thread, m.in_reply_to, is_falling_back=true) to outbound
messages
including attachments. Add tests for thread metadata and thread replies.
2026-02-20 18:03:26 +01:00
Alexander Minges 6a40665753 feat(matrix): support outbound attachments via message tool
- extend message tool with optional media paths for channel delivery

- switch Matrix uploads to stream providers and handle encrypted-room payloads

- add/expand tests for message tool media forwarding and Matrix upload edge cases
2026-02-20 18:02:40 +01:00
Alexander Minges d4d87bb4e5 fix(matrix): block outbound media when maxMediaBytes is zero 2026-02-20 18:02:15 +01:00
Alexander Minges a28ae51ce9 fix(matrix): handle matrix-nio upload tuple response 2026-02-20 18:02:14 +01:00
Alexander Minges 97cb85ee0b feat(matrix): add outbound media uploads and unify media limits with maxMediaBytes
- Use OutboundMessage.media for Matrix file/image/audio/video sends
- Apply effective media limit as min(m.upload.size, maxMediaBytes)
- Rename matrix config key maxInboundMediaBytes -> maxMediaBytes (no legacy fallback)
2026-02-20 18:02:13 +01:00
Alexander Minges bfd2018095 docs: update maxMediaBytes documentation to include blocking option
Add clarification that setting to 0 blocks all attachments
2026-02-20 18:01:21 +01:00
Alexander MingesandAlexander Minges 10de3bf329 refactor(matrix): use base media event filter for callbacks
- Replaces the explicit media event tuple with MATRIX_MEDIA_EVENT_FILTER
  based on
  media base classes: (RoomMessageMedia, RoomEncryptedMedia).
- Keeps MatrixMediaEvent as the static typing alias for media-specific
  handlers.
- Removes MatrixInboundEvent and uses RoomMessage in mention-related
  logic.
- Adds regression tests for:
  - callback registration using MATRIX_MEDIA_EVENT_FILTER
  - ensuring RoomMessageText is not matched by the media filter.
2026-02-20 17:58:37 +01:00
Alexander MingesandAlexander Minges 1103f000fc docs(matrix): clarify m.text body plaintext fallback note 2026-02-20 17:58:06 +01:00
Alexander Minges 9b06f682c3 docs(readme): document matrix e2eeEnabled option 2026-02-20 17:58:02 +01:00
Alexander Minges 566ad1dfc7 feat(matrix): make e2ee configurable with enabled default 2026-02-20 17:57:10 +01:00
Alexander Minges 085a311d4b docs(matrix): clarify typing keepalive spec notes 2026-02-20 17:56:28 +01:00
Alexander Minges 8b3171ca2b fix(matrix): include empty m.mentions in outgoing messages 2026-02-20 17:56:24 +01:00
Alexander Minges ca66ddb0bf feat(matrix): refresh typing indicator while processing 2026-02-20 17:56:15 +01:00
Alexander Minges a482a89df6 feat(matrix): support inbound media attachments 2026-02-20 17:56:11 +01:00
Alexander Minges 7b2adf9d9d docs(matrix): document raw html escaping in markdown renderer 2026-02-20 17:56:07 +01:00
Alexander Minges 6be7368a38 fix(matrix): sanitize formatted html with nh3 2026-02-20 17:55:59 +01:00
Alexander Minges 9b14869cb1 feat(matrix): support inline markdown html for url and super/subscript 2026-02-20 17:55:13 +01:00
Alexander Minges cc5cfe6847 test(matrix): cover mention policy and sender filtering 2026-02-20 17:55:09 +01:00
Alexander Minges fa2049fc60 feat(matrix): add group policy and strict mention gating 2026-02-20 17:55:05 +01:00
Alexander Minges 3200135f4b test(matrix): cover formatted body and markdown fallback 2026-02-20 17:54:42 +01:00
Alexander Minges e716c9caac feat(matrix): send markdown as formatted html messages 2026-02-20 17:54:39 +01:00
Alexander Minges 840ef7363f test(matrix): cover typing indicator lifecycle 2026-02-20 17:54:29 +01:00
Alexander Minges 45267b0730 feat(matrix): show typing while processing messages 2026-02-20 17:54:26 +01:00
Alexander Minges ffac42f9e5 refactor(matrix): replace logging depth magic number 2026-02-20 17:52:37 +01:00
Alexander Minges b294a682a8 chore(matrix): route matrix-nio logs through loguru 2026-02-20 17:52:36 +01:00
Alexander Minges b721f9f37d test(matrix): cover response callbacks and graceful shutdown 2026-02-20 17:52:34 +01:00
Alexander Minges 9d85393226 feat(matrix): add startup warnings and response error logging 2026-02-20 17:52:33 +01:00
Alexander Minges 7c33d3cbe2 feat(matrix): add configurable graceful sync shutdown 2026-02-20 17:52:32 +01:00
Re-bin 9a31571b6d fix: don't append interim assistant message before retry to avoid prefill errors 2026-02-20 16:51:37 +00:00
Alexander Minges 988b75624c test(matrix): add matrix channel behavior test 2026-02-20 17:48:16 +01:00
Alexander Minges c926569033 fix(matrix): guard store load without device id and allow invites by default 2026-02-20 17:48:15 +01:00
djmazeandAlexander Minges d3ddeb3067 fix: activate E2E and accept room invites in Matrix channels 2026-02-20 17:48:14 +01:00
Xubin RenandGitHub 21dd9e4112 Merge pull request #908 to route CLI interactive mode through message bus
refactor: route CLI interactive mode through message bus
2026-02-21 00:46:06 +08:00
Re-bin 7279ff0167 refactor: route CLI interactive mode through message bus for subagent support 2026-02-20 16:45:21 +00:00
Re-bin f8ffff98a5 Merge PR #892: fix MCP connection retry and concurrent connection guard 2026-02-20 16:09:13 +00:00
Re-bin 80b5e6cea0 Merge branch 'main' into pr-892 2026-02-20 16:06:17 +00:00
Re-bin 5ba3ee97a4 Merge PR #832: avoid duplicate reply when message tool already sent 2026-02-20 15:56:13 +00:00
Re-bin 132807a3fb refactor: simplify message tool turn tracking to a single boolean flag 2026-02-20 15:55:30 +00:00
Re-bin c8682512c9 Merge branch 'main' into pr-832 2026-02-20 15:47:16 +00:00
Re-bin b6610721f9 Merge PR #902: store session key in JSONL metadata to avoid lossy filename reconstruction 2026-02-20 15:43:06 +00:00
Re-bin d9cc144575 style: remove redundant comment in list_sessions 2026-02-20 15:42:24 +00:00
Re-bin 40867bff86 Merge branch 'main' into pr-902 2026-02-20 15:27:05 +00:00
Re-bin 5110b070dd Merge PR #900: split Discord messages exceeding 2000-character limit 2026-02-20 15:26:15 +00:00
Re-bin b853222c87 style: trim _send_payload docstring 2026-02-20 15:26:12 +00:00
Re-bin 9643b477da Merge branch 'main' into pr-900 2026-02-20 15:23:22 +00:00
Re-bin 44c2de2283 Merge PR #903: convert remaining f-string logger calls to loguru native format 2026-02-20 15:21:43 +00:00
Re-bin a33cb3e2dc Merge branch 'main' into pr-903 2026-02-20 15:21:11 +00:00
Re-bin ff0003de3f Merge PR #904: add media file upload support to Slack channel 2026-02-20 15:19:23 +00:00
Re-bin 6bcfbd9610 style: remove redundant comments and use loguru native format 2026-02-20 15:19:18 +00:00
Re-bin fe089abe5b Merge branch 'main' into pr-904 2026-02-20 15:17:04 +00:00
Re-bin 1d41dcd99a Merge PR #905: enable prompt caching for OpenRouter 2026-02-20 15:15:44 +00:00
Re-bin cc04bc4dd1 fix: check gateway's supports_prompt_caching instead of always returning False 2026-02-20 15:14:45 +00:00
tercerapersonaandGitHub b286457c85 add Openrouter prompt caching via cache_control 2026-02-20 11:34:50 -03:00
Nikolas de Hor 4cbd857250 fix: handle edge cases in message splitting and send failure
- _split_message: return empty list for empty/None content instead
  of a list with one empty string (Discord rejects empty content)
- _split_message: use pos <= 0 fallback to prevent empty chunks
  when content starts with a newline or space
- _send_payload: return bool to indicate success/failure
- send: abort remaining chunks when a chunk fails to send,
  preventing partial/corrupted message delivery
2026-02-20 10:09:04 -03:00
Nikolas de Hor f19baa8fc4 fix: convert remaining f-string logger calls to loguru native format
Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py
were missed in the original sweep. These can cause KeyError if interpolated
values contain curly braces, since loguru interprets them as format placeholders.
2026-02-20 10:01:38 -03:00
Alexander Minges 426ef71ce7 style(loop): drop formatting-only churn against upstream main 2026-02-20 13:57:39 +01:00
Nikolas de Hor 73530d51ac fix: store session key in JSONL metadata to avoid lossy filename reconstruction
list_sessions() previously reconstructed the session key by replacing all
underscores in the filename with colons. This is lossy: a key like
'cli:user_name' became 'cli:user:name' after round-tripping.

Now the actual key is persisted in the metadata line during save() and read
back in list_sessions(). Legacy files without the key field fall back to
replacing only the first underscore, which handles the common channel:chat_id
pattern correctly.

Closes #899
2026-02-20 09:57:11 -03:00
Nikolas de Hor 4c75e1673f fix: split Discord messages exceeding 2000-character limit
Discord's API rejects messages longer than 2000 characters with HTTP 400.
Previously, long agent responses were silently lost after retries exhausted.

Adds _split_message() (matching Telegram's approach) to chunk content at
line boundaries before sending. Only the first chunk carries the reply
reference. Retry logic extracted to _send_payload() for reuse across chunks.

Closes #898
2026-02-20 09:55:22 -03:00
Nikolas de Hor 37222f9c0a fix: add connecting guard to prevent concurrent MCP connection attempts
Addresses Codex review: concurrent callers could both pass the
_mcp_connected guard and race through _connect_mcp(). Added
_mcp_connecting flag set immediately to serialize attempts.
2026-02-20 09:38:22 -03:00
Nikolas de Hor 45f33853cf fix: only apply interim fallback when no tools were used
Addresses Codex review: if the model sent interim text then used tools,
the interim text should not be used as fallback for the final response.
2026-02-20 09:37:42 -03:00
Alexander Minges df022febaf refactor(loop): drop redundant Any typing in /new snapshot 2026-02-20 13:33:51 +01:00
Alexander Minges c1b5e8c8d2 fix(loop): lock /new snapshot and prune stale consolidation locks 2026-02-20 13:32:57 +01:00
Kim 8cc54b188d style(logging): use loguru parameterized formatting in suppression log 2026-02-20 20:25:46 +08:00
Nikolas de Hor 44f44b305a fix: move MCP connected flag after successful connection to allow retry
The flag was set before the connection attempt, so if any MCP server
was temporarily unavailable, the flag stayed True and MCP tools were
permanently lost for the session.

Closes #889
2026-02-20 09:24:48 -03:00
Nikolas de Hor 4eb07c44b9 fix: preserve interim content as fallback when retry produces empty response
Fixes regression from #825 where models that respond with final text
directly (no tools) had their answer discarded by the retry mechanism.

Closes #878
2026-02-20 09:21:27 -03:00
Kim ddae3e9d5f fix(agent): avoid duplicate final send when message tool already replied 2026-02-20 20:16:45 +08:00
Alexander Minges 9ada8e6854 fix(loop): require successful archival before /new clear 2026-02-20 13:06:07 +01:00
Alexander Minges 5f9eca4664 style(loop): remove formatting-only changes from upstream PR 881 2026-02-20 12:46:11 +01:00
Alexander Minges 755e424127 fix(loop): serialize /new consolidation and track task refs 2026-02-20 12:40:59 +01:00
Re-bin c8089021a5 Merge PR #795: sanitize messages and ensure content key for strict LLM providers 2026-02-20 11:27:28 +00:00
Re-bin 5cc019bf1a style: trim verbose comments in _sanitize_messages 2026-02-20 11:27:21 +00:00
Re-bin 0c2fea6d33 Merge branch 'main' into pr-795 2026-02-20 11:25:51 +00:00
Re-bin ddf7f92275 Merge PR #833: always send tool hint even when model has preceding text 2026-02-20 11:19:00 +00:00
Re-bin 8db91f59e2 style: remove trailing space 2026-02-20 11:18:57 +00:00
Re-bin b73e847e89 Merge branch 'main' into pr-833 2026-02-20 11:16:49 +00:00
Xubin RenandGitHub cd0a5affd5 Merge pull request #879 to make Telegram reply-to-message behavior configurable (default false)
feat: make Telegram reply-to-message behavior configurable, default false
2026-02-20 19:14:15 +08:00
Re-bin e1854c4373 feat: make Telegram reply-to-message behavior configurable, default false 2026-02-20 11:13:10 +00:00
Paul e39bbaa9be feat(slack): add media file upload support
Use files_upload_v2 API to upload media attachments in Slack messages.
This enables the message tool's media parameter to work correctly
when sending images or other files through the Slack channel.

Requires files:write OAuth scope.
2026-02-20 09:54:21 +00:00
Re-bin 792f80ce0c Merge PR #821: make cron run command actually execute the agent 2026-02-20 09:04:41 +00:00
Re-bin b97b1a5e91 fix: pass full agent config including mcp_servers to cron run command 2026-02-20 09:04:33 +00:00
Re-bin 0b34a43779 Merge branch 'main' into pr-821 2026-02-20 08:59:51 +00:00
Re-bin 698b09b4e7 Merge PR #815: reply to original Telegram message using message_id 2026-02-20 08:57:13 +00:00
Re-bin 44eb1bdca2 Merge branch 'main' into pr-815 2026-02-20 08:57:02 +00:00
Re-bin 9f0928fde6 Merge PR #807: support custom headers for MCP HTTP authentication 2026-02-20 08:50:39 +00:00
Re-bin f5fe74f578 style: move httpx import to top-level and fix README example for MCP headers 2026-02-20 08:49:49 +00:00
Re-bin bbd76e8f5b Merge branch 'main' into pr-807 2026-02-20 08:47:13 +00:00
Re-bin d609eba7d6 Merge PR #812: add VolcEngine LLM provider support 2026-02-20 08:45:47 +00:00
Re-bin 25efd1bc54 docs: update docs for providers 2026-02-20 08:45:42 +00:00
Re-bin 82a318759f Merge branch 'main' into pr-812 2026-02-20 08:42:31 +00:00
Re-bin 72a622aea1 Merge PR #824: handle /help in Telegram directly, bypassing ACL 2026-02-20 08:40:32 +00:00
Re-bin 2f315ec567 style: trim _on_help docstring 2026-02-20 08:39:26 +00:00
Re-bin 7957f84e3d Merge branch 'main' into pr-824 2026-02-20 08:36:34 +00:00
Re-bin 7d7c1e3edf Merge PR #823: prevent duplicate memory consolidation tasks per session 2026-02-20 08:35:27 +00:00
Re-bin 686471bd8d Merge branch 'main' into pr-823 2026-02-20 08:33:45 +00:00
Re-bin ef0eef9f74 Merge PR #825: allow one retry for models that send interim text before tool calls 2026-02-20 08:31:57 +00:00
Re-bin 2383dcb3a8 style: use loguru native format and trim comments in interim retry 2026-02-20 08:31:48 +00:00
Re-bin 0660d614f6 Merge branch 'main' into pr-825 2026-02-20 08:24:26 +00:00
Re-bin 5855d92619 Merge PR #854: add Anthropic prompt caching via cache_control 2026-02-20 08:21:55 +00:00
Re-bin 9ffae47c13 refactor(litellm): remove redundant comments in cache_control methods 2026-02-20 08:21:02 +00:00
Re-bin afa0513243 Merge branch 'main' into pr-854 2026-02-20 08:17:32 +00:00
Re-bin 72f449e868 Merge PR #644: handle non-string values in memory consolidation 2026-02-20 08:13:07 +00:00
Re-bin 002de466d7 chore: remove test file for memory consolidation fix 2026-02-20 08:12:23 +00:00
Re-bin a9bffdc06f Merge branch 'main' into pr-644 2026-02-20 08:08:55 +00:00
Re-bin a79e56a44d Merge PR #763: add service-layer timezone validation for cron jobs 2026-02-20 08:06:36 +00:00
Re-bin 2b8c082428 Merge branch 'main' into pr-763 2026-02-20 08:04:48 +00:00
Re-bin 5d7a27ebf2 Merge PR #653: resolve relative file paths against workspace 2026-02-20 08:03:27 +00:00
Re-bin e17342ddfc fix: pass workspace to file tools in subagent 2026-02-20 08:03:24 +00:00
Re-bin 55ac4b729e Merge branch 'main' into pr-653 2026-02-20 08:01:08 +00:00
Re-bin ae0347042b Merge PR #455: fix UTF-8 encoding and ensure_ascii for non-ASCII support 2026-02-20 08:00:32 +00:00
Re-bin 73fdd0dd45 fix: complete ensure_ascii=False and UTF-8 encoding migration 2026-02-20 07:59:32 +00:00
Re-bin 4c2f64db14 Merge PR #864: use loguru native formatting to prevent KeyError on curly braces 2026-02-20 07:55:52 +00:00
Re-bin 37252a4226 fix: complete loguru native formatting migration across all files 2026-02-20 07:55:34 +00:00
Re-bin 0bde1d89fa Merge branch 'main' into pr-864 2026-02-20 07:47:48 +00:00
Re-bin 0e6683ad4b Merge PR #870: remove dead pub/sub code from MessageBus 2026-02-20 07:42:50 +00:00
Re-bin b26a2e1af1 Merge branch 'main' into pr-870 2026-02-20 07:41:17 +00:00
Tanish Rajput 0d3dc57a65 feat: add matrix (Element) chat channel support 2026-02-20 11:57:48 +05:30
AlexanderMerkelandClaude Opus 4.6 0001f286b5 fix: remove dead pub/sub code from MessageBus
`subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero
callers — `ChannelManager._dispatch_outbound()` handles all outbound
routing via `consume_outbound()` directly. Remove the dead methods and
their unused imports (`Callable`, `Awaitable`, `logger`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:00:25 -07:00
dxtime f3c7337356 feat: Added custom headers for MCP Auth use, update README.md 2026-02-20 08:31:52 +08:00
Rudolfs Tilgass afca0278ad fix(memory): Enforce memory consolidation schema with a tool call 2026-02-19 22:14:51 +01:00
Nikolas de Hor 53b83a38e2 fix: use loguru native formatting to prevent KeyError on messages containing curly braces
Closes #857
2026-02-19 17:19:36 -03:00
Re-bin d22929305f Merge PR #820: fix safety guard false positive on 'format' in URLs 2026-02-19 17:48:37 +00:00
Re-bin fbfb030a6e chore: remove network-dependent test file for shell guard 2026-02-19 17:48:09 +00:00
Re-bin 1c51fbeeee Merge branch 'main' into pr-820 2026-02-19 17:44:30 +00:00
Re-bin c1296746e3 Merge PR #851: wait for killed process after shell timeout to prevent fd leaks 2026-02-19 17:43:05 +00:00
Re-bin fe7b0b64c1 Merge branch 'main' into pr-851 2026-02-19 17:42:23 +00:00
Re-bin 125524f5c2 Merge PR #836: fix Codex provider routing for GitHub Copilot models 2026-02-19 17:39:52 +00:00
Re-bin b11f0ce6a9 fix: prefer explicit provider prefix over keyword match to fix Codex routing 2026-02-19 17:39:44 +00:00
Re-bin d78368bb2f Merge branch 'main' into pr-836 2026-02-19 17:35:19 +00:00
Re-bin 9a00a274e5 Merge PR #844: support sending images, audio, and files for Feishu 2026-02-19 17:34:01 +00:00
Re-bin 3890f1a7dd refactor(feishu): clean up send() and remove dead code 2026-02-19 17:33:08 +00:00
Re-bin eea4942025 Merge branch 'main' into pr-844 2026-02-19 17:29:35 +00:00
Re-bin d748e6eca3 fix: pin dependency version ranges 2026-02-19 17:28:13 +00:00
tercerapersonaandClaude Sonnet 4.6 3b4763b3f9 feat: add Anthropic prompt caching via cache_control
Inject cache_control: {"type": "ephemeral"} on the system message and
last tool definition for providers that support prompt caching. Adds
supports_prompt_caching flag to ProviderSpec (enabled for Anthropic only)
and skips caching when routing through a gateway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 11:05:22 -03:00
Nikolas de Hor c86dbc9f45 fix: wait for killed process after shell timeout to prevent fd leaks
When a shell command times out, process.kill() is called but the
process object was never awaited after that. This leaves subprocess
pipes undrained and file descriptors open. If many commands time out,
fd leaks accumulate.

Add a bounded wait (5s) after kill to let the process fully terminate
and release its resources.
2026-02-19 10:27:11 -03:00
Nikolas de Hor 1b49bf9602 fix: avoid duplicate messages on retry and reset final_content
Address review feedback:
- Remove on_progress call for interim text to prevent duplicate
  messages when the model simply answers a direct question
- Reset final_content to None before continue to avoid stale
  interim text leaking as the final response on empty retry

Closes #705
2026-02-19 10:26:49 -03:00
Ubuntu d08c022255 feat(feishu): support sending images, audio, and files
- Add image upload via im.v1.image.create API
- Add file upload via im.v1.file.create API
- Support sending images (.png, .jpg, .gif, etc.) as image messages
- Support sending audio (.opus) as voice messages
- Support sending other files as file messages
- Refactor send() to handle media attachments before text content
2026-02-19 16:31:00 +08:00
PiEgg 9789307dd6 Fix Codex provider routing for GitHub Copilot models 2026-02-19 13:30:02 +08:00
Darye 523b2982f4 fix: fixed not logging tool uses if a think fragment had them attached.
if a think fragment had a tool attached, the tool use would not log. now it does
2026-02-19 05:22:00 +01:00
chtangwin 124c611426 Fix: Add ensure_ascii=False to WhatsApp send payload
The send() payload contains user message content (msg.content) which
may include non-ASCII characters (e.g. CJK, German umlauts, emoji).

The auth frame and Discord heartbeat/identify payloads are left
unchanged as they only carry ASCII protocol fields.
2026-02-18 18:46:23 -08:00
chtangwin a2379a08ac Fix: Ensure UTF-8 encoding and ensure_ascii=False for remaining file/JSON operations 2026-02-18 18:37:17 -08:00
chtangwin c7b5dd9350 Fix: Ensure UTF-8 encoding for all file operations 2026-02-18 18:28:54 -08:00
Nikolas de Hor 464352c664 fix: allow one retry for models that send interim text before tool calls
Some LLM providers (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
initial text-only response like "Let me investigate..." before actually
making tool calls. The agent loop previously broke immediately on any
text response without tool calls, preventing these models from ever
using tools.

Now, when the model responds with text but hasn't used any tools yet,
the loop forwards the text as progress to the user and gives the model
one additional iteration to make tool calls. This is limited to a
single retry to prevent infinite loops.

Closes #705
2026-02-18 21:31:12 -03:00
Nikolas de Hor 33d760d312 fix: handle /help command directly in Telegram, bypassing ACL check
The /help command was routed through _forward_command → _handle_message
→ is_allowed(), which denied access to users not in the allowFrom list.
Since /help is purely informational, it should be accessible to all
users — similar to how /start already works with its own handler.

Add a dedicated _on_help handler that replies directly without going
through the message bus access control.

Closes #687
2026-02-18 21:31:11 -03:00
Nikolas de Hor 107a380e61 fix: prevent duplicate memory consolidation tasks per session
Add a `_consolidating` set to track which sessions have an active
consolidation task. Skip creating a new task if one is already in
progress for the same session key, and clean up the flag when done.

This prevents the excessive API calls reported when messages exceed
the memory_window threshold — previously every single message after
the threshold triggered a new background consolidation.

Closes #751
2026-02-18 21:31:09 -03:00
4367038a95 fix: make cron run command actually execute the agent
Wire up an AgentLoop with an on_job callback in the cron_run CLI
command so the job's message is sent to the agent and the response
is printed. Previously, CronService was created with no on_job
callback, causing _execute_job to skip execution silently and
always report success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 15:42:33 -06:00
ruby childsandClaude Opus 4.6 536ed60a05 Fix safety guard false positive on 'format' in URLs
The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked
commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`)
because `\b` fires at the boundary between `?` (non-word) and `f` (word).

Split into two patterns:
- `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone
  command (start of line or after shell operators)
- `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:39:06 -05:00
Darye 3ac5513004 If given a message_id to telegram provider send, the bot will try to reply to that message 2026-02-18 20:27:48 +01:00
Darye c865b293a9 feat: enhance message context handling by adding message_id parameter 2026-02-18 20:18:27 +01:00
Your Name 1663517998 feat: Add VolcEngine LLM provider support
- Add VolcEngine ProviderSpec entry in registry.py
- Add volcengine to ProvidersConfig class in schema.py
- Update model providers table in README.md
- Add description about VolcEngine coding plan endpoint
2026-02-19 03:02:16 +08:00
4a85cd9a11 fix(cron): add service-layer timezone validation
Adds `_validate_schedule_for_add()` to `CronService.add_job` so that
invalid or misplaced `tz` values are rejected before a job is persisted,
regardless of which caller (CLI, tool, etc.) invoked the service.

Surfaces the resulting `ValueError` in `nanobot cron add` via a
`try/except` so the CLI exits cleanly with a readable error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:33:23 +01:00
dxtime c5b4331e69 feature: Added custom headers for MCP Auth use. 2026-02-19 01:21:17 +08:00
Xubin RenandGitHub 8de36d398f docs: update news about release information 2026-02-18 23:09:55 +08:00
Re-bin 1f1f5b2d27 docs: update v0.1.4 release news 2026-02-18 14:41:13 +00:00
Re-bin b14d4711c0 release: v0.1.4 2026-02-18 14:31:26 +00:00
Xubin RenandGitHub 92d279924f Merge pull request #802 to enable stream intermediate progress
feat: stream intermediate progress to user during tool execution
2026-02-18 22:28:37 +08:00
Re-bin 715b2db24b feat: stream intermediate progress to user during tool execution 2026-02-18 14:23:51 +00:00
Ivan e44f14379a fix: sanitize messages and ensure 'content' for strict LLM providers
- Strip non-standard keys like 'reasoning_content' before sending to LLM
- Always include 'content' key in assistant messages (required by StepFun)
- Add _sanitize_messages to LiteLLMProvider to prevent 400 BadRequest errors
2026-02-18 11:57:58 +03:00
Re-bin ce4f00529e Merge PR #713: scope sessions to workspace with migration and tool metadata 2026-02-18 05:16:00 +00:00
Re-bin 27a131830f refine: migrate legacy sessions on load and simplify get_history 2026-02-18 05:09:57 +00:00
Re-bin 5c61f30546 Merge branch 'main' into pr-713 2026-02-18 04:58:59 +00:00
Re-bin 4c577761e2 Merge PR #630: add SiliconFlow provider 2026-02-18 03:53:00 +00:00
Re-bin 80a5a8c983 feat: add siliconflow provider support 2026-02-18 03:52:53 +00:00
Re-bin df09ba1232 Merge branch 'main' into pr-630 2026-02-18 03:13:00 +00:00
Re-bin 7f8a3dfc0f Merge PR #312: add GitHub Copilot OAuth login and provider status display 2026-02-18 03:09:35 +00:00
Re-bin d54831a35f feat: add github copilot oauth login and improve provider status display 2026-02-18 03:09:09 +00:00
Re-bin 8f6dd8708f Merge branch 'main' into pr-312 2026-02-18 02:57:11 +00:00
Re-bin 74bec26698 Merge branch 'main' of https://github.com/HKUDS/nanobot 2026-02-18 02:51:16 +00:00
ras_botandCursor e5e5f02e73 merge: upstream/main into feat/add-siliconflow-provider, resolve schema conflict
- Keep siliconflow in ProvidersConfig
- Keep openai_codex and github_copilot from upstream/main

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 10:50:15 +08:00
Re-bin 43590145ee Merge PR #784: configurable Slack thread reply and reaction emoji 2026-02-18 02:48:28 +00:00
Xubin RenandGitHub 95fead24e0 Merge pull request #786 to add custom provider with direct openai-compatible support
feat: add custom provider with direct openai-compatible support
2026-02-18 10:40:26 +08:00
Re-bin e2a0d63909 feat: add custom provider with direct openai-compatible support 2026-02-18 02:39:15 +00:00
Jeroen Evens 16127d49f9 [github] Fix Oauth login 2026-02-17 23:07:04 +01:00
Jeroen Evens b161fa4f9a [github] Add Github Copilot 2026-02-17 23:07:04 +01:00
Hyudryu 72db01db63 slack: Added replyInThread logic and custom react emoji in config 2026-02-17 13:42:57 -08:00
Xubin RenandGitHub 831eb07945 docs: update security guideline 2026-02-18 02:00:30 +08:00
Re-bin 05d06b1eb8 docs: update line count 2026-02-17 17:58:36 +00:00
Re-bin ed2aa7fe67 Merge PR #765: add Docker Compose support 2026-02-17 17:56:04 +00:00
Re-bin aad1df5b9b Simplify Docker Compose docs and remove fixed CLI container name 2026-02-17 17:55:48 +00:00
Re-bin fae573573f Merge branch 'main' into pr-765 2026-02-17 17:50:56 +00:00
Re-bin 090b8fb768 Merge PR #746: enable cron tool in CLI agent mode 2026-02-17 17:49:22 +00:00
Re-bin 7d7d6bcadc Merge branch 'main' into pr-746 2026-02-17 17:46:46 +00:00
Re-bin 711d03e8ac Merge PR #766: use Pydantic alias_generator to fix MCP env key conversion 2026-02-17 17:34:31 +00:00
Re-bin 941c3d9826 style: restore single-line formatting for readability 2026-02-17 17:34:24 +00:00
Simon Guigui 4d4d629928 fix(config): mcpServers env variables should not be converted to snake case 2026-02-17 15:19:21 +01:00
Rajasimman S c03f2b670b 🐳 feat: add Docker Compose support for easy deployment
Add docker-compose.yml with gateway and CLI services, resource limits,
and comprehensive documentation for Docker Compose usage.
2026-02-17 18:50:03 +05:30
Re-bin 8053193a36 Merge PR #747: add media file sending support for Telegram 2026-02-17 10:38:05 +00:00
Re-bin 5ad9c837df refactor: clean up telegram media sending logic 2026-02-17 10:37:55 +00:00
Re-bin c81cc07032 Merge branch 'main' into pr-747 2026-02-17 10:24:26 +00:00
Re-bin 79d15e6023 Merge PR #748: avoid sending empty content entries in assistant messages 2026-02-17 08:59:49 +00:00
Re-bin 1db05c881d fix: omit empty content in assistant messages 2026-02-17 08:59:05 +00:00
Re-bin 80d1ff69ad Merge branch 'main' into pr-748 2026-02-17 08:57:27 +00:00
Re-bin d89736a484 Merge PR #720: add GitHub Copilot provider support 2026-02-17 08:41:16 +00:00
Re-bin f5c5b13ff0 refactor: use is_oauth flag instead of hardcoded provider name check 2026-02-17 08:41:09 +00:00
Re-bin 12e59ecaae Merge branch 'main' into pr-720 2026-02-17 08:33:34 +00:00
Re-bin d405dcb5a8 Merge PR #744: add timezone support for cron scheduling 2026-02-17 08:31:00 +00:00
Re-bin 6bae6a617f fix(cron): fix timezone display bug, add tz validation and skill docs 2026-02-17 08:30:52 +00:00
Re-bin 2c3a568e46 Merge branch 'main' into pr-744 2026-02-17 08:21:13 +00:00
Re-bin cf4dce5df0 docs: update clawhub news 2026-02-17 08:20:50 +00:00
Re-bin 8509a81120 docs: update 15/16 Feb news 2026-02-17 08:19:23 +00:00
Xubin RenandGitHub 23726cb802 Merge pull request #758 to add ClawHub skill
feat: add ClawHub skill
2026-02-17 16:15:47 +08:00
Re-bin 5735f9bdce feat: add ClawHub skill for searching and installing agent skills from the public registry 2026-02-17 08:14:16 +00:00
nano bot 56bc8b5677 fix: avoid sending empty content entries in assistant messages 2026-02-17 03:52:08 +00:00
Darye 778a93370a Enable Cron management on CLI Agent. 2026-02-17 03:52:54 +01:00
jopo ae903e983c fix(cron): improve timezone scheduling and tz propagation 2026-02-16 17:49:19 -08:00
DaryeandGitHub 0a2a9a77b7 Merge branch 'HKUDS:main' into telegram-media 2026-02-16 21:08:41 +01:00
Darye 23b7e1ef5e Handle media files (voice messages, audio, images, documents) on Telegram Channel 2026-02-16 16:29:03 +01:00
DaryeandGitHub 96f63aee06 Merge branch 'HKUDS:main' into github_copilot 2026-02-16 15:03:01 +01:00
Darye 5033ac1759 Added Github Copilot Provider 2026-02-16 15:02:12 +01:00
Re-bin a219a91bc5 feat: support openclaw/clawhub skill metadata format 2026-02-16 13:42:33 +00:00
Xubin RenandGitHub 1207b89adb Merge pull request #717 from xek/slack-mrkdwn-formatting
slack: use slackify-markdown for proper mrkdwn formatting
2026-02-16 21:08:21 +08:00
Re-bin b0871497e0 Merge PR #717: use slackify-markdown for Slack formatting 2026-02-16 13:07:06 +00:00
Grzegorz Grasza c9926153b2 Add table-to-text conversion for Slack messages
Slack has no native table support, so Markdown tables are passed
through verbatim by slackify-markdown.  Pre-process tables into
readable key-value rows before converting to mrkdwn.

Assisted-by: Claude 4.6 Opus (Anthropic)
2026-02-16 14:03:33 +01:00
Grzegorz Grasza ed5593bbe0 slack: use slackify-markdown for proper mrkdwn formatting
Replace the regex-based Markdown-to-Slack converter with the
slackify-markdown library, which uses a proper Markdown parser
(markdown-it-py, already a dependency) to correctly handle headings,
bold/italic, code blocks, links, bullet lists, and strikethrough.

The regex approach didn't handle headings (###), bullet lists (* ),
or code block protection, causing raw Markdown to leak into Slack
messages.

Net -40 lines.

Assisted-by: Claude 4.6 Opus (Anthropic)
2026-02-16 13:56:30 +01:00
Re-bin c28e6771a9 Merge PR #694: fix Telegram message too long error 2026-02-16 12:39:45 +00:00
Re-bin db0e8aa61b fix: handle Telegram message length limit with smart splitting 2026-02-16 12:39:39 +00:00
Kiplangatkorir 8f49b52079 Scope sessions to workspace with legacy fallback 2026-02-16 15:22:15 +03:00
Re-bin 48a14edbda Merge branch 'main' into pr-694 2026-02-16 12:16:05 +00:00
Re-bin 3cdb8a0db2 Merge PR #701: fix Telegram command allowlist matching 2026-02-16 12:11:09 +00:00
Re-bin ffbb264a5d fix: consistent sender_id for Telegram command allowlist matching 2026-02-16 12:11:03 +00:00
Re-bin ba923c0205 Merge branch 'main' into pr-701 2026-02-16 12:07:58 +00:00
Re-bin e8e7215d3e refactor: simplify Slack markdown-to-mrkdwn conversion 2026-02-16 11:57:55 +00:00
Re-bin 3706903978 Merge branch 'main' into pr-704 2026-02-16 11:52:02 +00:00
Re-bin 1ce586e9f5 fix: resolve Codex provider bugs and simplify implementation 2026-02-16 11:43:36 +00:00
Re-bin 9e5f7348fe Merge branch 'main' into pr-151 2026-02-16 09:19:40 +00:00
Aleksander W. Oleszkiewicz (Alek)andGitHub fe0341da5b Fix regex for URL formatting in Slack channel 2026-02-16 09:58:38 +01:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 5d683da38f Fix regex for URL and image URL formatting 2026-02-16 09:53:20 +01:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 90be900448 Enhance Slack message formatting with new regex rules
Added regex substitutions for strikethrough, URL formatting, and image URLs in Slack message conversion.
2026-02-16 09:49:44 +01:00
Thomas Lisankie 51d22b7ef4 Fix: _forward_command now builds sender_id with username for allowlist matching 2026-02-16 00:14:34 -05:00
Harry Zhou 40f4834f30 Merge remote-tracking branch 'upstream/main' 2026-02-16 11:40:07 +08:00
zhouzhuojie 9bfc86af41 refactor(telegram): extract message splitting into helper function
- Added _split_message() helper for cleaner separation of concerns
- Simplified send() method by using the helper
- Net -18 lines for the message splitting feature
2026-02-15 22:49:01 +00:00
zhouzhuojie 203aa154d4 fix(telegram): split long messages to avoid Message is too long error
Telegram has a 4096 character limit per message. This fix:
- Splits messages longer than 4000 chars into multiple chunks
- Prefers breaking at newline boundaries to preserve formatting
- Falls back to space boundaries if no newlines available
- Forces split at max length if no good boundaries exist
- Adds comprehensive tests for message splitting logic
2026-02-15 22:39:31 +00:00
Re-bin a5265c263d docs: update readme structure 2026-02-15 16:41:27 +00:00
Aleksander W. Oleszkiewicz (Alek)andGitHub 7e2d801ffc Implement markdown conversion for Slack messages
Add markdown conversion for Slack messages including italics, bold, and table formatting.
2026-02-15 15:51:19 +01:00
Re-bin 82074a7715 docs: update news section 2026-02-15 14:03:51 +00:00
Xubin RenandGitHub 69f80ec634 Merge pull request #664 to use json_repair for robust LLM response parsing
fix: use json_repair for robust LLM response parsing
2026-02-15 16:12:47 +08:00
Re-bin 49fec3684a fix: use json_repair for robust LLM response parsing 2026-02-15 08:11:33 +00:00
Re-bin 728874179c Merge PR #554: add MCP support 2026-02-15 07:03:08 +00:00
Re-bin 52cf1da30a fix: store original MCP tool name, make close_mcp public 2026-02-15 07:00:27 +00:00
Re-bin 54d5f637e7 merge main into pr-554 2026-02-15 06:12:15 +00:00
Re-bin e2ef1f9d48 docs: add custom provider guideline 2026-02-15 06:02:45 +00:00
Re-bin fd480bb6f5 Merge branch 'main' into pr-625 2026-02-15 05:27:16 +00:00
Oleg Medvedev fbbbdc727d fix(tools): resolve relative file paths against workspace
File tools now resolve relative paths (e.g., "test.txt") against the
workspace directory instead of the current working directory. This fixes
failures when models use simple filenames instead of full paths.

- Add workspace parameter to _resolve_path() in filesystem.py
- Update all file tools to accept workspace in constructor
- Pass workspace when registering tools in AgentLoop
2026-02-14 13:51:18 -06:00
Harry Zhou b523b277b0 fix(agent): handle non-string values in memory consolidation
Fix TypeError when LLM returns JSON objects instead of strings for
history_entry or memory_update.

Changes:
- Update prompt to explicitly require string values with example
- Add type checking and conversion for non-string values
- Use json.dumps() for consistent JSON formatting

Fixes potential memory consolidation failures when LLM interprets
the prompt loosely and returns structured objects instead of strings.
2026-02-14 23:48:21 +08:00
Xubin RenandGitHub 3411035447 Merge pull request #617 from themavik/fix/523-clamp-max-tokens
fix(providers): clamp max_tokens to >= 1 before calling LiteLLM
2026-02-14 18:02:20 +08:00
Xubin RenandGitHub 6e3f86714c Merge pull request #629 from C-Li/feishu_optmize
增加支持飞书富文本内容接收。Add support for receiving Feishu rich text content.
2026-02-14 17:51:10 +08:00
Zhiwei LiandCursor 66cd21e6ec feat: add SiliconFlow provider support
Add SiliconFlow (硅基流动) as an OpenAI-compatible gateway provider.
SiliconFlow hosts multiple models (Qwen, DeepSeek, etc.) via an
OpenAI-compatible API at https://api.siliconflow.cn/v1.

Changes:
- Add ProviderSpec for siliconflow in providers/registry.py
- Add siliconflow field to ProvidersConfig in config/schema.py

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:27:10 +11:00
Ahwei 5e082690d8 refactor(feishu): support both direct and localized post content formats 2026-02-14 14:37:23 +08:00
Ahwei 4e4eb21d23 feat(feishu): Add rich text message content extraction feature
Newly added the _extract_post_text function to extract plain text content from Feishu rich text messages, supporting the parsing of titles, text, links, and @mentions.
2026-02-14 12:14:31 +08:00
Ahwei d3f6c95ceb refactor(cron): simplify timezone logic and merge conditional branches
With tz: Use the specified timezone (e.g., "Asia/Shanghai").
Without tz: Use the local timezone (datetime.now().astimezone().tzinfo) instead of defaulting to UTC
2026-02-14 10:27:09 +08:00
Ahwei 153c83e340 fix(cron): add timezone support for accurate next run time calculation
When schedule.tz is present, use the specified timezone to calculate the next execution time, ensuring scheduled tasks trigger correctly across different timezones.
2026-02-14 10:23:54 +08:00
Re-bin f821e95d3c fix: wire max_tokens/temperature to all chat calls, clean up redundant comments 2026-02-14 01:40:37 +00:00
Re-bin 155fc48b29 merge: resolve conflict with main, keep extracted _run_agent_loop with temperature 2026-02-14 01:22:17 +00:00
Re-bin 59d5e3cc4f docs: update line count 2026-02-14 01:14:47 +00:00
Re-bin 2f2c55f921 fix: add missing comma and type annotation for temperature param 2026-02-14 01:13:49 +00:00
Re-bin 9a83301ea6 Merge branch 'main' into pr-560 2026-02-14 01:10:51 +00:00
Re-bin d6d73c8167 docs: update .gitignore to remove tests 2026-02-14 01:03:16 +00:00
Re-bin 3b580fd6c8 tests: update test_commands.py 2026-02-14 01:02:58 +00:00
Re-bin 12540ba8cb feat: improve onboard with merge-or-overwrite prompt 2026-02-14 00:58:43 +00:00
Re-bin 835a10e1a9 merge: resolve conflict with main, keep load-merge-save approach 2026-02-14 00:51:29 +00:00
The Mavik 10e9e0cdc9 fix(providers): clamp max_tokens to >= 1 before calling LiteLLM (#523) 2026-02-13 17:08:10 -05:00
Xubin RenandGitHub bc045fae1f Merge pull request #604 to add custom provider and non-destructive onboard
feat: add custom provider and non-destructive onboard
2026-02-14 00:08:40 +08:00
Re-bin b76cf05c3a feat: add custom provider and non-destructive onboard 2026-02-13 16:05:00 +00:00
chengyongru a3f4bb74ff fix: increase max_messages to 500 as temporary workaround
Temporarily increase default max_messages from 50 to 500 to allow
more context in conversations until a proper consolidation strategy
is implemented.
2026-02-13 22:10:53 +08:00
Luke Milby bd55bf5278 cleaned up logic for onboarding 2026-02-13 08:56:37 -05:00
Luke MilbyandGitHub a9d911c80d Merge branch 'HKUDS:main' into feature/onboard_workspace 2026-02-13 08:45:31 -05:00
Luke Milby 8a11490798 updated logic for onboard function not ask for to overwrite workspace since the logic already ensures nothing will be overwritten. Added onboard command tests and removed tests from gitignore 2026-02-13 08:43:49 -05:00
qiupinhua 442136a313 fix: remove uv.lock 2026-02-13 18:52:43 +08:00
qiupinhua 1ae47058d9 fix: refactor code structure for improved readability and maintainability 2026-02-13 18:51:30 +08:00
qiupinhua 09c7e7aded feat: change OAuth login command for providers 2026-02-13 18:37:21 +08:00
chengyongru afc8d50659 test: add comprehensive tests for consolidate offset functionality
Add 26 new test cases covering:
- Consolidation trigger conditions (exceed window, within keep count, no new messages)
- last_consolidated edge cases (exceeds message count, negative value, new messages after consolidation)
- archive_all mode (/new command behavior)
- Cache immutability (messages list never modified during consolidation)
- Slice logic (messages[last_consolidated:-keep_count])
- Empty and boundary sessions (empty, single message, exact keep count, very large)

Refactor tests with helper functions to reduce code duplication by 25%:
- create_session_with_messages() - creates session with specified message count
- assert_messages_content() - validates message content range
- get_old_messages() - encapsulates standard slice logic

All 35 tests passing.
2026-02-13 16:30:43 +08:00
chengyongru 98a762452a fix: useasyncio.create_task to avoid block 2026-02-13 15:36:04 +08:00
chengyongru 740294fd74 fix: history messages should not be change[kvcache] 2026-02-13 15:10:07 +08:00
wymcmhandGitHub 3e9f6d0b6b Merge branch 'main' into fix/config-temperature 2026-02-13 13:07:37 +08:00
Luke Milby f016025f63 add feature to onboarding that will ask to generate missing workspace files 2026-02-12 22:20:56 -05:00
lemon a3599b97b9 fix: bug #370, support temperature configuration 2026-02-12 19:12:38 +08:00
Sergio Sánchez Vallés d30523f460 fix(mcp): clean up connections on exit in interactive and gateway modes 2026-02-12 10:44:25 +01:00
Sergio Sánchez Vallés 61e9f7f58a chore: revert unrelated changes, keep only MCP support 2026-02-12 10:17:44 +01:00
Sergio Sánchez Vallés 16af3dd1cb Merge branch 'main' into feature/mcp-support 2026-02-12 10:12:49 +01:00
Sergio Sánchez Vallés 7052387f07 Merge branch 'feature/mcp-support' of github.com:SergioSV96/nanobot into feature/mcp-support 2026-02-12 10:12:10 +01:00
Sergio Sánchez Vallés e89afe61f1 feat(tools): add mcp support 2026-02-12 10:09:00 +01:00
Sergio Sánchez Vallés cb5964c201 feat(tools): add mcp support 2026-02-12 10:01:30 +01:00
pinhua33 c6915d27e9 Merge remote-tracking branch 'upstream/main' into feature/codex-oauth 2026-02-10 00:44:03 +08:00
pinhua33 51f97efcb8 refactor: simplify Codex URL handling by removing unnecessary function 2026-02-09 16:04:04 +08:00
pinhua33 fc67d11da9 feat: add OAuth login command for OpenAI Codex 2026-02-09 15:39:30 +08:00
pinhua33 ae908e0dcd Merge upstream/main: resolve conflicts with OAuth support 2026-02-09 15:13:11 +08:00
pinhua33 08efe6ad3f refactor: add OAuth support to provider registry system
- Add is_oauth and oauth_provider fields to ProviderSpec
- Update _make_provider() to use registry for OAuth provider detection
- Update get_provider() to support OAuth providers (no API key required)
- Mark OpenAI Codex as OAuth-based provider in registry

This improves the provider registry architecture to support OAuth-based
authentication flows, making it extensible for future OAuth providers.

Benefits:
- OAuth providers are now registry-driven (not hardcoded)
- Extensible design: new OAuth providers only need registry entry
- Backward compatible: existing API key providers unaffected
- Clean separation: OAuth logic centralized in registry
2026-02-08 16:48:11 +08:00
pinhua33 c1dc8d3f55 fix: integrate OpenAI Codex provider with new registry system
- Add OpenAI Codex ProviderSpec to registry.py
- Add openai_codex config field to ProvidersConfig in schema.py
- Mark Codex as OAuth-based (no API key required)
- Set appropriate default_api_base for Codex API

This integrates the Codex OAuth provider with the refactored
provider registry system introduced in upstream commit 299d8b3.
2026-02-08 16:33:46 +08:00
pinhua33 6bca38b89d Merge remote-tracking branch 'upstream/main' into feature/codex-oauth 2026-02-08 15:47:10 +08:00
pinhua33 5bcfb550d5 Merge remote-tracking branch 'origin/main' into feature/codex-oauth 2026-02-08 13:49:25 +08:00
pinhua33 42c2d83d70 refactor: remove Codex OAuth implementation and integrate oauth-cli-kit 2026-02-08 13:41:47 +08:00
pinhua33 b639192e46 fix: codex tool calling failed unexpectedly 2026-02-06 11:52:03 +08:00
pinhua33 f20afc8d2f feat: add Codex login status to nanobot status command 2026-02-06 00:39:02 +08:00
pinhua33 01420f4dd6 refactor: remove unused functions and simplify code 2026-02-06 00:26:02 +08:00
qiupinhua d4e65319ee refactor: split codex oauth logic to several files 2026-02-05 17:53:00 +08:00
qiupinhua 5bff24096c feat: implement OpenAI Codex OAuth login and provider integration 2026-02-05 17:39:18 +08:00
133 changed files with 17373 additions and 1310 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
+2 -1
View File
@@ -15,8 +15,9 @@ docs/
*.pyzz
.venv/
venv/
.worktrees/
__pycache__/
poetry.lock
.pytest_cache/
tests/
botpy.log
tests/
+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"]
+359 -61
View File
@@ -16,22 +16,40 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
<details>
<summary>Earlier news</summary>
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
</details>
## Key Features of nanobot:
🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot.
@@ -107,17 +125,26 @@ nanobot onboard
**2. Configure** (`~/.nanobot/config.json`)
For OpenRouter - recommended for global users:
Add or merge these **two parts** into your config (other options have defaults).
*Set your API key* (e.g. OpenRouter, recommended for global users):
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
}
}
```
*Set your model* (optionally pin a provider — defaults to auto-detection):
```json
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
"model": "anthropic/claude-opus-4-5",
"provider": "openrouter"
}
}
}
@@ -126,63 +153,26 @@ For OpenRouter - recommended for global users:
**3. Chat**
```bash
nanobot agent -m "What is 2+2?"
nanobot agent
```
That's it! You have a working AI assistant in 2 minutes.
## 🖥️ Local Models (vLLM)
Run nanobot with your own local models using vLLM or any OpenAI-compatible server.
**1. Start your vLLM server**
```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```
**2. Configure** (`~/.nanobot/config.json`)
```json
{
"providers": {
"vllm": {
"apiKey": "dummy",
"apiBase": "http://localhost:8000/v1"
}
},
"agents": {
"defaults": {
"model": "meta-llama/Llama-3.1-8B-Instruct"
}
}
}
```
**3. Chat**
```bash
nanobot agent -m "Hello from my local LLM!"
```
> [!TIP]
> The `apiKey` can be any non-empty string for local servers that don't require authentication.
## 💬 Chat Apps
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
Connect nanobot to your favorite chat platform.
| Channel | Setup |
|---------|-------|
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
| **Mochat** | Medium (claw token + websocket) |
| **DingTalk** | Medium (app credentials) |
| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
| **QQ** | Easy (app credentials) |
| Channel | What you need |
|---------|---------------|
| **Telegram** | Bot token from @BotFather |
| **Discord** | Bot token + Message Content intent |
| **WhatsApp** | QR code scan |
| **Feishu** | App ID + App Secret |
| **Mochat** | Claw token (auto-setup available) |
| **DingTalk** | App Key + App Secret |
| **Slack** | Bot token + App-Level token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@@ -319,6 +309,72 @@ nanobot gateway
</details>
<details>
<summary><b>Matrix (Element)</b></summary>
Install Matrix dependencies first:
```bash
pip install nanobot-ai[matrix]
```
**1. Create/choose a Matrix account**
- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
- Confirm you can log in with Element.
**2. Get credentials**
- You need:
- `userId` (example: `@nanobot:matrix.org`)
- `accessToken`
- `deviceId` (recommended so sync tokens can be restored across restarts)
- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings.
**3. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"userId": "@nanobot:matrix.org",
"accessToken": "syt_xxx",
"deviceId": "NANOBOT01",
"e2eeEnabled": true,
"allowFrom": [],
"groupPolicy": "open",
"groupAllowFrom": [],
"allowRoomMentions": false,
"maxMediaBytes": 20971520
}
}
}
```
> Keep a persistent `matrix-store` and stable `deviceId` — encrypted session state is lost if these change across restarts.
| Option | Description |
|--------|-------------|
| `allowFrom` | User IDs allowed to interact. Empty = all senders. |
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
**4. Run**
```bash
nanobot gateway
```
</details>
<details>
<summary><b>WhatsApp</b></summary>
@@ -596,21 +652,119 @@ Config file: `~/.nanobot/config.json`
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
<details>
<summary><b>OpenAI Codex (OAuth)</b></summary>
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
**1. Login:**
```bash
nanobot provider login openai-codex
```
**2. Set model** (merge into `~/.nanobot/config.json`):
```json
{
"agents": {
"defaults": {
"model": "openai-codex/gpt-5.1-codex"
}
}
}
```
**3. Chat:**
```bash
nanobot agent -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
</details>
<details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.your-provider.com/v1"
}
},
"agents": {
"defaults": {
"model": "your-model-name"
}
}
}
```
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
</details>
<details>
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
Run your own model with vLLM or any OpenAI-compatible server, then add to config:
**1. Start the server** (example):
```bash
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
```
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
*Provider (key can be any non-empty string for local):*
```json
{
"providers": {
"vllm": {
"apiKey": "dummy",
"apiBase": "http://localhost:8000/v1"
}
}
}
```
*Model:*
```json
{
"agents": {
"defaults": {
"model": "meta-llama/Llama-3.1-8B-Instruct"
}
}
}
```
</details>
<details>
<summary><b>Adding a New Provider (Developer Guide)</b></summary>
@@ -657,13 +811,70 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
</details>
### MCP (Model Context Protocol)
> [!TIP]
> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
Add MCP servers to your `config.json`:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
},
"my-remote-mcp": {
"url": "https://example.com/mcp/",
"headers": {
"Authorization": "Bearer xxxxx"
}
}
}
}
}
```
Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
```json
{
"tools": {
"mcpServers": {
"my-slow-server": {
"url": "https://example.com/mcp/",
"toolTimeout": 120
}
}
}
}
```
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
### Security
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
@@ -678,6 +889,7 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
| `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status |
| `nanobot provider login openai-codex` | OAuth login for providers |
| `nanobot channels login` | Link WhatsApp (scan QR) |
| `nanobot channels status` | Show channel status |
@@ -700,12 +912,46 @@ nanobot cron remove <job_id>
</details>
<details>
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown
## Periodic Tasks
- [ ] Check weather forecast and send a summary
- [ ] Scan inbox for urgent emails
```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
</details>
## 🐳 Docker
> [!TIP]
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
Build and run nanobot in a container:
### Docker Compose
```bash
docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
```
```bash
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
docker compose logs -f nanobot-gateway # view logs
docker compose down # stop
```
### Docker
```bash
# Build the image
@@ -725,6 +971,59 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!"
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status
```
## 🐧 Linux Service
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
**1. Find the nanobot binary path:**
```bash
which nanobot # e.g. /home/user/.local/bin/nanobot
```
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
```ini
[Unit]
Description=Nanobot Gateway
After=network.target
[Service]
Type=simple
ExecStart=%h/.local/bin/nanobot gateway
Restart=always
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=%h
[Install]
WantedBy=default.target
```
**3. Enable and start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now nanobot-gateway
```
**Common operations:**
```bash
systemctl --user status nanobot-gateway # check status
systemctl --user restart nanobot-gateway # restart after config changes
journalctl --user -u nanobot-gateway -f # follow logs
```
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
>
> ```bash
> loginctl enable-linger $USER
> ```
## 📁 Project Structure
```
@@ -753,7 +1052,6 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13)
- [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection
+1 -1
View File
@@ -5,7 +5,7 @@
If you discover a security vulnerability in nanobot, please report it by:
1. **DO NOT** open a public GitHub issue
2. Create a private security advisory on GitHub or contact the repository maintainers
2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
3. Include:
- Description of the vulnerability
- Steps to reproduce
+31
View File
@@ -0,0 +1,31 @@
x-common-config: &common-config
build:
context: .
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
services:
nanobot-gateway:
container_name: nanobot-gateway
<<: *common-config
command: ["gateway"]
restart: unless-stopped
ports:
- 18790:18790
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-cli:
<<: *common-config
profiles:
- cli
command: ["status"]
stdin_open: true
tty: true
@@ -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
+1 -1
View File
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework
"""
__version__ = "0.1.0"
__version__ = "0.1.4.post2"
__logo__ = "🐈"
+18 -16
View File
@@ -45,10 +45,14 @@ class ContextBuilder:
if bootstrap:
parts.append(bootstrap)
# Memory context
memory = self.memory.get_memory_context()
if memory:
parts.append(f"# Memory\n\n{memory}")
# Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching)
# MEMORY.md is excluded from system prompt as it changes frequently (consolidator),
# but the agent can still read/grep it via tools.
knowledge_file = self.memory.memory_dir / "KNOWLEDGE.md"
if knowledge_file.exists():
knowledge = knowledge_file.read_text(encoding="utf-8").strip()
if knowledge:
parts.append(f"# Knowledge\n\n{knowledge}")
# Skills - progressive loading
# 1. Always-loaded skills: include full content
@@ -71,27 +75,18 @@ Skills with available="false" need dependencies installed first - you can try in
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
"""Get the core identity section with runtime context."""
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
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
## Current Time
{now} ({tz})
## Runtime
{runtime}
@@ -107,7 +102,14 @@ For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
To recall past events, grep {workspace_path}/memory/HISTORY.md
## Visibility Markers
Messages marked with [HIDDEN:{{signature}}] were not sent to the user. These markers
are cryptographically signed by the system to track internal reasoning and background
tasks. Do NOT generate [HIDDEN:*] patterns yourself - outputs containing forged
visibility markers will be rejected."""
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""
+425 -323
View File
@@ -1,26 +1,36 @@
"""Agent loop: the core processing engine."""
from __future__ import annotations
import asyncio
import json
import re
import time
from contextlib import AsyncExitStack
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.context import ContextBuilder
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import SessionManager
from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig
from nanobot.cron.service import CronService
class AgentLoop:
@@ -35,32 +45,42 @@ class AgentLoop:
5. Sends responses back
"""
_TOOL_RESULT_MAX_CHARS = 500
def __init__(
self,
bus: MessageBus,
provider: LLMProvider,
workspace: Path,
model: str | None = None,
max_iterations: int = 20,
memory_window: int = 50,
max_iterations: int = 40,
temperature: float = 0.1,
max_tokens: int = 4096,
memory_window: int = 100,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
enable_memory_tool: bool = True,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
channels_config: ChannelsConfig | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
self.bus = bus
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.temperature = temperature
self.max_tokens = max_tokens
self.memory_window = memory_window
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
self.enable_memory_tool = enable_memory_tool
self.context = ContextBuilder(workspace)
self.sessions = session_manager or SessionManager(workspace)
@@ -70,142 +90,178 @@ class AgentLoop:
workspace=workspace,
bus=bus,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
brave_api_key=brave_api_key,
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._processing_lock = asyncio.Lock()
self._quota_cache: dict[str, Any] = {} # {model: str, cached_at: float}
self._quota_cache_ttl: float = 300.0 # 5 minutes
self._register_default_tools()
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (restrict to workspace if configured)
allowed_dir = self.workspace if self.restrict_to_workspace else None
self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
# Shell tool
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
))
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
# Cron tool (for scheduling)
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.subagents))
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
if self.enable_memory_tool:
from nanobot.agent.tools.anthropic import MemoryTool20250818
self.tools.register(MemoryTool20250818(workspace=self.workspace))
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
logger.info("Agent loop started")
while self._running:
try:
# Wait for next message
msg = await asyncio.wait_for(
self.bus.consume_inbound(),
timeout=1.0
)
# Process it
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
return
self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers
try:
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
self._mcp_connected = True
except Exception as e:
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
if self._mcp_stack:
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
except Exception as e:
logger.error(f"Error processing message: {e}")
# Send error response
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}"
))
except asyncio.TimeoutError:
continue
await self._mcp_stack.aclose()
except Exception:
pass
self._mcp_stack = None
finally:
self._mcp_connecting = False
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
for name in ("message", "spawn", "cron"):
if tool := self.tools.get(name):
if hasattr(tool, "set_context"):
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
"""
Process a single inbound message.
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"]
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
# 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
Returns:
The response message, or None if no response needed.
"""
# Handle system messages (subagent announces)
# The chat_id contains the original "channel:chat_id" to route back to
if msg.channel == "system":
return await self._process_system_message(msg)
with open(rate_limits_file, encoding="utf-8") as f:
data = json.load(f)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
# 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
# Get or create session
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# 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
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
await self._consolidate_memory(session, archive_all=True)
session.clear()
self.sessions.save(session)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 New session started. Memory consolidated.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
# 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")
# Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window:
await self._consolidate_memory(session)
# Cache the decision
self._quota_cache = {"model": model, "cached_at": now}
return model
# Update tool contexts
message_tool = self.tools.get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_context(msg.channel, msg.chat_id)
except Exception as e:
logger.error(f"Error checking quota: {e}, defaulting to Sonnet")
return "anthropic/claude-sonnet-4-5"
spawn_tool = self.tools.get("spawn")
if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(msg.channel, msg.chat_id)
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."
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(msg.channel, msg.chat_id)
with open(rate_limits_file, encoding="utf-8") as f:
data = json.load(f)
# Build initial messages (use get_history for LLM-formatted messages)
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
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")
# Agent loop
# Get selected model for current usage
model = self._select_model_based_on_quota()
model_name = "Opus" if "opus" in model.lower() else "Sonnet"
return f"""📊 Quota Status:
• Usage: {used:,} / {limit:,} tokens ({used_pct:.1f}%)
• Resets: {reset_time}
• Current model: {model_name}"""
except Exception as e:
return f"⚠️ Error reading quota: {e}"
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""Remove <think>…</think> blocks that some models embed in content."""
if not text:
return None
return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
def _fmt(tc):
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def _run_agent_loop(
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
) -> tuple[str | None, list[str], list[dict]]:
"""Run the agent iteration loop. Returns (final_content, tools_used, messages)."""
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
@@ -213,23 +269,31 @@ class AgentLoop:
while iteration < self.max_iterations:
iteration += 1
# Call LLM
# Select model based on quota
selected_model = self._select_model_based_on_quota()
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
model=selected_model,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
# Handle tool calls
if response.has_tool_calls:
# Add assistant message with tool calls
if on_progress:
clean = self._strip_think(response.content)
if clean:
await on_progress(clean)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments) # Must be JSON string
"arguments": json.dumps(tc.arguments, ensure_ascii=False)
}
}
for tc in response.tool_calls
@@ -239,209 +303,262 @@ class AgentLoop:
reasoning_content=response.reasoning_content,
)
# Execute tools
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
# Interleaved CoT: reflect before next action
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
# No tool calls, we're done
final_content = response.content
break
if final_content is None:
if iteration >= self.max_iterations:
final_content = f"Reached {self.max_iterations} iterations without completion."
else:
final_content = "I've completed processing but have no response to give."
# Log response preview
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
# Save to session (include tool names so consolidation sees what happened)
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self.sessions.save(session)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info(f"Processing system message from {msg.sender_id}")
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
origin_channel = parts[0]
origin_chat_id = parts[1]
else:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
# Use the origin session for context
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
# Update tool contexts
message_tool = self.tools.get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_context(origin_channel, origin_chat_id)
spawn_tool = self.tools.get("spawn")
if isinstance(spawn_tool, SpawnTool):
spawn_tool.set_context(origin_channel, origin_chat_id)
cron_tool = self.tools.get("cron")
if isinstance(cron_tool, CronTool):
cron_tool.set_context(origin_channel, origin_chat_id)
# Build messages with the announce content
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
channel=origin_channel,
chat_id=origin_chat_id,
)
# Agent loop (limited for announce handling)
iteration = 0
final_content = None
while iteration < self.max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
)
if response.has_tool_calls:
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments)
}
}
for tc in response.tool_calls
]
clean = self._strip_think(response.content)
messages = self.context.add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
messages, clean, reasoning_content=response.reasoning_content,
)
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
# Interleaved CoT: reflect before next action
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
final_content = clean
break
if final_content is None:
final_content = "Background task completed."
if final_content is None and iteration >= self.max_iterations:
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task. You can try breaking the task into smaller steps."
)
# Save to session (mark as system message in history)
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
return final_content, tools_used, messages
return OutboundMessage(
channel=origin_channel,
chat_id=origin_chat_id,
content=final_content
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
try:
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError:
continue
if msg.content.strip().lower() == "/stop":
await self._handle_stop(msg)
else:
task = asyncio.create_task(self._dispatch(msg))
self._active_tasks.setdefault(msg.session_key, []).append(task)
task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
async def _handle_stop(self, msg: InboundMessage) -> None:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop."
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message under the global lock."""
async with self._processing_lock:
try:
response = await self._process_message(msg)
if response is not None:
await self.bus.publish_outbound(response)
elif msg.channel == "cli":
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", msg.session_key)
raise
except Exception:
logger.exception("Error processing message for session {}", msg.session_key)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Sorry, I encountered an error.",
))
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
async def _process_message(
self,
msg: InboundMessage,
session_key: str | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
# System messages: parse origin from chat_id ("channel:chat_id")
if msg.channel == "system":
channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
else ("cli", msg.chat_id))
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=self.memory_window)
messages = self.context.build_messages(
history=history,
current_message=msg.content, channel=channel, chat_id=chat_id,
)
final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# Slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())
self._consolidating.add(session.key)
try:
async with lock:
snapshot = session.messages[session.last_consolidated:]
if snapshot:
temp = Session(key=session.key)
temp.messages = list(snapshot)
if not await self._consolidate_memory(temp, archive_all=True):
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
finally:
self._consolidating.discard(session.key)
if not lock.locked():
self._consolidation_locks.pop(session.key, None)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/quota — Show quota status\n/help — Show available commands")
if cmd == "/quota":
status = self._get_quota_status()
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
self._consolidating.add(session.key)
lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())
async def _consolidate_and_unlock():
try:
async with lock:
await self._consolidate_memory(session)
finally:
self._consolidating.discard(session.key)
if not lock.locked():
self._consolidation_locks.pop(session.key, None)
_task = asyncio.current_task()
if _task is not None:
self._consolidation_tasks.discard(_task)
_task = asyncio.create_task(_consolidate_and_unlock())
self._consolidation_tasks.add(_task)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
history = session.get_history(max_messages=self.memory_window)
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
)
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
if not session.messages:
return
memory = MemoryStore(self.workspace)
if archive_all:
old_messages = session.messages
keep_count = 0
else:
keep_count = min(10, max(2, self.memory_window // 2))
old_messages = session.messages[:-keep_count]
if not old_messages:
return
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
# Format messages for LLM (include tool names when available)
lines = []
for m in old_messages:
if not m.get("content"):
continue
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
final_content, _, all_msgs = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys:
if final_content is None:
final_content = "I've completed processing but have no response to give."
1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None
## Current Long-term Memory
{current_memory or "(empty)"}
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=final_content,
metadata=msg.metadata or {},
)
## Conversation to Process
{conversation}
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
for m in messages[skip:]:
entry = {k: v for k, v in m.items() if k != "reasoning_content"}
role, content = entry.get("role"), entry.get("content")
if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS:
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
elif role == "user":
if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
continue
if isinstance(content, list):
entry["content"] = [
{"type": "text", "text": "[image]"} if (
c.get("type") == "image_url"
and c.get("image_url", {}).get("url", "").startswith("data:image/")
) else c for c in content
]
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
Respond with ONLY valid JSON, no markdown fences."""
try:
response = await self.provider.chat(
messages=[
{"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt},
],
model=self.model,
)
text = (response.content or "").strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json.loads(text)
if entry := result.get("history_entry"):
memory.append_history(entry)
if update := result.get("memory_update"):
if update != current_memory:
memory.write_long_term(update)
session.messages = session.messages[-keep_count:] if keep_count else []
self.sessions.save(session)
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
except Exception as e:
logger.error(f"Memory consolidation failed: {e}")
async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
"""Delegate to MemoryStore.consolidate(). Returns True on success."""
return await MemoryStore(self.workspace).consolidate(
session, self.provider, self.model,
archive_all=archive_all, memory_window=self.memory_window,
)
async def process_direct(
self,
@@ -449,25 +566,10 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""
Process a message directly (for CLI or cron usage).
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
Returns:
The agent's response.
"""
msg = InboundMessage(
channel=channel,
sender_id="user",
chat_id=chat_id,
content=content
)
response = await self._process_message(msg, session_key=session_key)
"""Process a message directly (for CLI or cron usage)."""
await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
+120
View File
@@ -1,9 +1,46 @@
"""Memory system for persistent agent memory."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.utils.helpers import ensure_dir
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session
_SAVE_MEMORY_TOOL = [
{
"type": "function",
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing "
"facts plus new ones. Return unchanged if nothing new.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
@@ -28,3 +65,86 @@ class MemoryStore:
def get_memory_context(self) -> str:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
async def consolidate(
self,
session: Session,
provider: LLMProvider,
model: str,
*,
archive_all: bool = False,
memory_window: int = 50,
) -> bool:
"""Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
Returns True on success (including no-op), False on failure.
"""
if archive_all:
old_messages = session.messages
keep_count = 0
logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
else:
keep_count = memory_window // 2
if len(session.messages) <= keep_count:
return True
if len(session.messages) - session.last_consolidated <= 0:
return True
old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return True
logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
lines = []
for m in old_messages:
if not m.get("content"):
continue
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
current_memory = self.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{chr(10).join(lines)}"""
try:
response = await provider.chat(
messages=[
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
model=model,
)
if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
return False
args = response.tool_calls[0].arguments
# Some providers return arguments as a JSON string instead of dict
if isinstance(args, str):
args = json.loads(args)
if not isinstance(args, dict):
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
return False
if entry := args.get("history_entry"):
if not isinstance(entry, str):
entry = json.dumps(entry, ensure_ascii=False)
self.append_history(entry)
if update := args.get("memory_update"):
if not isinstance(update, str):
update = json.dumps(update, ensure_ascii=False)
if update != current_memory:
self.write_long_term(update)
session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
return True
except Exception:
logger.exception("Memory consolidation failed")
return False
+2 -2
View File
@@ -167,10 +167,10 @@ class SkillsLoader:
return content
def _parse_nanobot_metadata(self, raw: str) -> dict:
"""Parse nanobot metadata JSON from frontmatter."""
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
return data.get("nanobot", {}) if isinstance(data, dict) else {}
return (data.get("nanobot") or data.get("openclaw") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
+71 -21
View File
@@ -15,6 +15,9 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.subagent_message import SubagentMessageTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
class SubagentManager:
@@ -40,18 +43,24 @@ class SubagentManager:
self.provider = provider
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
# Default to Sonnet, not the provider default (Opus).
# Quota switching only affects the main agent's own requests, not SubagentManager.
# Explicit model overrides (e.g. Haiku workers) still take precedence.
self.model = model or "claude-sonnet-4-6"
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_results: dict[str, str] = {}
async def spawn(
self,
task: str,
label: str | None = None,
model: str | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
origin_metadata: dict[str, Any] | None = None,
) -> str:
"""
Spawn a subagent to execute a task in the background.
@@ -61,6 +70,7 @@ class SubagentManager:
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.
@@ -71,11 +81,12 @@ class SubagentManager:
origin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"metadata": origin_metadata or {},
}
# Create background task
bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin)
self._run_subagent(task_id, task, display_label, origin, model=model)
)
self._running_tasks[task_id] = bg_task
@@ -83,7 +94,7 @@ class SubagentManager:
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 (id: {task_id}). I'll notify you when it completes."
return f"Subagent [{display_label}] started. Task ID: {task_id}"
async def _run_subagent(
self,
@@ -91,12 +102,13 @@ class SubagentManager:
task: str,
label: str,
origin: dict[str, str],
model: str | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info(f"Subagent [{task_id}] starting task: {label}")
try:
# Build subagent tools (no message tool, no spawn tool)
# Build subagent tools (no message tool)
tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
@@ -111,6 +123,21 @@ class SubagentManager:
tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool())
# Message tool for communicating with user (via main agent)
message_tool = SubagentMessageTool(
bus=self.bus,
origin_channel=origin["channel"],
origin_chat_id=origin["chat_id"],
origin_metadata=origin.get("metadata"),
)
tools.register(message_tool)
# Spawn tool for creating child subagents
spawn_tool = SpawnTool(manager=self)
spawn_tool.set_context("subagent", origin["chat_id"], origin.get("metadata"))
tools.register(spawn_tool)
tools.register(WaitForSubagentsTool(manager=self))
# Build messages with subagent-specific prompt
system_prompt = self._build_subagent_prompt(task)
messages: list[dict[str, Any]] = [
@@ -119,7 +146,7 @@ class SubagentManager:
]
# Run agent loop (limited iterations)
max_iterations = 15
max_iterations = 50
iteration = 0
final_result: str | None = None
@@ -129,7 +156,7 @@ class SubagentManager:
response = await self.provider.chat(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
model=model or self.model,
)
if response.has_tool_calls:
@@ -189,6 +216,15 @@ class SubagentManager:
"""Announce the subagent result to the main agent via the message bus."""
status_text = "completed successfully" if status == "ok" else "failed"
# ALWAYS store result so wait_for_subagents can find it
self._task_results[task_id] = result
# Child subagents (spawned by other subagents) don't announce - parent waits for them
if origin["channel"] == "subagent":
logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)")
return
# Top-level subagents announce via bus to trigger main agent
announce_content = f"""[Subagent '{label}' {status_text}]
Task: {task}
@@ -199,11 +235,13 @@ Result:
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
# Inject as system message to trigger main agent
# Propagate metadata from origin (e.g. suppress_output)
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content,
metadata=origin.get("metadata", {}),
)
await self.bus.publish_inbound(msg)
@@ -211,34 +249,27 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
from datetime import datetime
import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
return f"""# Subagent
## Current Time
{now} ({tz})
You are a subagent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
2. Your final response will be reported back to the main agent
3. Do not initiate conversations or take on side tasks
4. Be concise but informative in your findings
1. Run `exec date` as your very first action to get the current date and time
2. Stay focused - complete only the assigned task, nothing else
3. Your final response will be reported back to the main agent
4. Do not initiate conversations or take on side tasks
5. Be concise but informative in your findings
## What You Can Do
- Read and write files in the workspace
- Execute shell commands
- Search the web and fetch web pages
- Send messages to the main agent (via the message tool)
- Spawn child subagents for parallel tasks
- Complete the task thoroughly
## What You Cannot Do
- Send messages directly to users (no message tool available)
- Spawn other subagents
- Access the main agent's conversation history
- Access the main agent's conversation history directly
## Workspace
Your workspace is at: {self.workspace}
@@ -246,6 +277,25 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
When you have completed the task, provide a clear summary of your findings or actions."""
async def wait_for(self, task_ids: list[str]) -> str:
"""Wait for specified child subagents to complete and return their results."""
tasks_to_wait = [
self._running_tasks[tid]
for tid in task_ids
if tid in self._running_tasks
]
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
results = []
for tid in task_ids:
result = self._task_results.get(tid)
if result is not None:
results.append(f"[{tid}]:\n{result}")
else:
results.append(f"[{tid}]: No result found (invalid ID or task failed before storing)")
return "\n\n---\n\n".join(results)
def get_running_count(self) -> int:
"""Return the number of currently running subagents."""
return len(self._running_tasks)
+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,
}
+23 -3
View File
@@ -50,6 +50,10 @@ class CronTool(Tool):
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
},
"at": {
"type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
@@ -68,30 +72,46 @@ class CronTool(Tool):
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
if action == "add":
return self._add_job(message, every_seconds, cron_expr, at)
return self._add_job(message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
def _add_job(
self,
message: str,
every_seconds: int | None,
cron_expr: str | None,
tz: str | None,
at: str | None,
) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
# Build schedule
delete_after = False
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr)
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
from datetime import datetime
dt = datetime.fromisoformat(at)
+49 -16
View File
@@ -1,23 +1,31 @@
"""File system tools: read, write, edit."""
import difflib
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
"""Resolve path and optionally enforce directory restriction."""
resolved = Path(path).expanduser().resolve()
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
"""Resolve path against workspace (if relative) and enforce directory restriction."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
p = workspace / p
resolved = p.resolve()
if allowed_dir:
try:
resolved.relative_to(allowed_dir.resolve())
except ValueError:
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved
class ReadFileTool(Tool):
"""Tool to read file contents."""
def __init__(self, allowed_dir: Path | None = None):
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -43,7 +51,7 @@ class ReadFileTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._allowed_dir)
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
if not file_path.exists():
return f"Error: File not found: {path}"
if not file_path.is_file():
@@ -60,7 +68,8 @@ class ReadFileTool(Tool):
class WriteFileTool(Tool):
"""Tool to write content to a file."""
def __init__(self, allowed_dir: Path | None = None):
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -90,10 +99,10 @@ class WriteFileTool(Tool):
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._allowed_dir)
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Successfully wrote {len(content)} bytes to {path}"
return f"Successfully wrote {len(content)} bytes to {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
@@ -103,7 +112,8 @@ class WriteFileTool(Tool):
class EditFileTool(Tool):
"""Tool to edit a file by replacing text."""
def __init__(self, allowed_dir: Path | None = None):
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -137,14 +147,14 @@ class EditFileTool(Tool):
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._allowed_dir)
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
if not file_path.exists():
return f"Error: File not found: {path}"
content = file_path.read_text(encoding="utf-8")
if old_text not in content:
return f"Error: old_text not found in file. Make sure it matches exactly."
return self._not_found_message(old_text, content, path)
# Count occurrences
count = content.count(old_text)
@@ -154,17 +164,40 @@ class EditFileTool(Tool):
new_content = content.replace(old_text, new_text, 1)
file_path.write_text(new_content, encoding="utf-8")
return f"Successfully edited {path}"
return f"Successfully edited {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing file: {str(e)}"
@staticmethod
def _not_found_message(old_text: str, content: str, path: str) -> str:
"""Build a helpful error when old_text is not found."""
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = len(old_lines)
best_ratio, best_start = 0.0, 0
for i in range(max(1, len(lines) - window + 1)):
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
if ratio > best_ratio:
best_ratio, best_start = ratio, i
if best_ratio > 0.5:
diff = "\n".join(difflib.unified_diff(
old_lines, lines[best_start : best_start + window],
fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
))
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
class ListDirTool(Tool):
"""Tool to list directory contents."""
def __init__(self, allowed_dir: Path | None = None):
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -190,7 +223,7 @@ class ListDirTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str:
try:
dir_path = _resolve_path(path, self._allowed_dir)
dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
if not dir_path.exists():
return f"Error: Directory not found: {path}"
if not dir_path.is_dir():
+99
View File
@@ -0,0 +1,99 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
from contextlib import AsyncExitStack
from typing import Any
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
self._session = session
self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}"
self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._tool_timeout = tool_timeout
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
timeout=self._tool_timeout,
)
except asyncio.TimeoutError:
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect to configured MCP servers and register their tools."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
for name, cfg in mcp_servers.items():
try:
if cfg.command:
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
# Always provide an explicit httpx client so MCP HTTP transport does not
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.headers or None,
follow_redirects=True,
timeout=None,
)
)
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
logger.warning("MCP server '{}': no command or url configured, skipping", name)
continue
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
except Exception as e:
logger.error("MCP server '{}': failed to connect: {}", name, e)
+18 -1
View File
@@ -4,6 +4,7 @@ from typing import Any, Callable, Awaitable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
from nanobot.session import SessionManager
class MessageTool(Tool):
@@ -12,10 +13,12 @@ class MessageTool(Tool):
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
sessions: SessionManager | None = None,
default_channel: str = "",
default_chat_id: str = ""
):
self._send_callback = send_callback
self._sessions = sessions
self._default_channel = default_channel
self._default_chat_id = default_chat_id
@@ -45,6 +48,11 @@ class MessageTool(Tool):
"type": "string",
"description": "The message content to send"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of media file paths or URLs to attach"
},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
@@ -60,6 +68,7 @@ class MessageTool(Tool):
async def execute(
self,
content: str,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
**kwargs: Any
@@ -76,11 +85,19 @@ class MessageTool(Tool):
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content
content=content,
media=media or []
)
try:
await self._send_callback(msg)
if self._sessions:
session_key = f"{channel}:{chat_id}"
session = self._sessions.get_or_create(session_key)
session.add_message("assistant", content)
self._sessions.save(session)
return f"Message sent to {channel}:{chat_id}"
except Exception as e:
return f"Error sending message: {str(e)}"
+34 -8
View File
@@ -32,19 +32,32 @@ class ToolRegistry:
return name in self._tools
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()]
"""Get tool definitions for all registered tools.
async def execute(self, name: str, params: dict[str, Any]) -> str:
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 as string.
Tool execution result (ToolResult, CLIResult, or string).
Raises:
KeyError: If tool not found.
@@ -54,13 +67,26 @@ class ToolRegistry:
return f"Error: Tool '{name}' not found"
try:
errors = tool.validate_params(params)
if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
return await tool.execute(**params)
# Duck typing - support both native and function tools
if hasattr(tool, 'to_params'):
# Native Anthropic tool - call directly via __call__, no validation needed
return await tool(**params)
else:
# Legacy function tool - validate then execute
errors = tool.validate_params(params)
if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
return await tool.execute(**params)
except Exception as e:
return f"Error executing {name}: {str(e)}"
def get_tools(self) -> list[Any]:
"""Get list of tool objects (not definitions).
Returns tool objects which can be inspected for metadata like beta_flag.
"""
return list(self._tools.values())
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
+15 -1
View File
@@ -19,6 +19,7 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
path_append: str = "",
):
self.timeout = timeout
self.working_dir = working_dir
@@ -26,7 +27,8 @@ class ExecTool(Tool):
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"\b(format|mkfs|diskpart)\b", # disk operations
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
@@ -34,6 +36,7 @@ class ExecTool(Tool):
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
@property
def name(self) -> str:
@@ -66,12 +69,17 @@ class ExecTool(Tool):
if guard_error:
return guard_error
env = os.environ.copy()
if self.path_append:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
try:
@@ -81,6 +89,12 @@ class ExecTool(Tool):
)
except asyncio.TimeoutError:
process.kill()
# Wait for the process to fully terminate so pipes are
# drained and file descriptors are released.
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
return f"Error: Command timed out after {self.timeout} seconds"
output_parts = []
+10 -2
View File
@@ -20,11 +20,13 @@ class SpawnTool(Tool):
self._manager = manager
self._origin_channel = "cli"
self._origin_chat_id = "direct"
self._origin_metadata: dict[str, Any] = {}
def set_context(self, channel: str, chat_id: str) -> None:
def set_context(self, channel: str, chat_id: str, metadata: dict[str, Any] | None = None) -> None:
"""Set the origin context for subagent announcements."""
self._origin_channel = channel
self._origin_chat_id = chat_id
self._origin_metadata = metadata or {}
@property
def name(self) -> str:
@@ -51,15 +53,21 @@ class SpawnTool(Tool):
"type": "string",
"description": "Optional short label for the task (for display)",
},
"model": {
"type": "string",
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
},
},
"required": ["task"],
}
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str:
async def execute(self, task: str, label: str | None = None, model: str | None = None, **kwargs: Any) -> str:
"""Spawn a subagent to execute the given task."""
return await self._manager.spawn(
task=task,
label=label,
model=model,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
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)
+15 -6
View File
@@ -58,12 +58,21 @@ class WebSearchTool(Tool):
}
def __init__(self, api_key: str | None = None, max_results: int = 5):
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
self._init_api_key = api_key
self.max_results = max_results
@property
def api_key(self) -> str:
"""Resolve API key at call time so env/config changes are picked up."""
return self._init_api_key or os.environ.get("BRAVE_API_KEY", "")
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
if not self.api_key:
return "Error: BRAVE_API_KEY not configured"
return (
"Error: Brave Search API key not configured. "
"Set it in ~/.nanobot/config.json under tools.web.search.apiKey "
"(or export BRAVE_API_KEY), then restart the gateway."
)
try:
n = min(max(count or self.max_results, 1), 10)
@@ -116,7 +125,7 @@ class WebFetchTool(Tool):
# Validate URL before fetching
is_valid, error_msg = _validate_url(url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
try:
async with httpx.AsyncClient(
@@ -131,7 +140,7 @@ class WebFetchTool(Tool):
# JSON
if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2), "json"
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
# HTML
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
doc = Document(r.text)
@@ -146,9 +155,9 @@ class WebFetchTool(Tool):
text = text[:max_chars]
return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code,
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text})
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e), "url": url})
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _to_markdown(self, html: str) -> str:
"""Convert HTML to markdown."""
+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)
+2 -1
View File
@@ -16,11 +16,12 @@ class InboundMessage:
timestamp: datetime = field(default_factory=datetime.now)
media: list[str] = field(default_factory=list) # Media URLs
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions
@property
def session_key(self) -> str:
"""Unique key for session identification."""
return f"{self.channel}:{self.chat_id}"
return self.session_key_override or f"{self.channel}:{self.chat_id}"
@dataclass
+22
View File
@@ -20,6 +20,7 @@ class MessageBus:
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
self._correlation_store: dict[str, asyncio.Future] = {}
self._running = False
async def publish_inbound(self, msg: InboundMessage) -> None:
@@ -38,6 +39,27 @@ class MessageBus:
"""Consume the next outbound message (blocks until available)."""
return await self.outbound.get()
def register_correlation(self, correlation_id: str) -> asyncio.Future:
"""Register a Future to be resolved when a matching outbound message appears."""
loop = asyncio.get_running_loop()
future = loop.create_future()
self._correlation_store[correlation_id] = future
return future
def resolve_correlation(self, msg: OutboundMessage) -> None:
"""Check if an outbound message has a correlation_id and resolve the matching Future."""
cid = msg.metadata.get("correlation_id") if msg.metadata else None
if cid and cid in self._correlation_store:
future = self._correlation_store.pop(cid)
if not future.done():
future.set_result(msg.content)
def cancel_correlation(self, correlation_id: str) -> None:
"""Cancel and remove a pending correlation."""
future = self._correlation_store.pop(correlation_id, None)
if future and not future.done():
future.cancel()
def subscribe_outbound(
self,
channel: str,
+12 -4
View File
@@ -74,6 +74,10 @@ class BaseChannel(ABC):
if not allow_list:
return True
# Wildcard allows everyone
if "*" in allow_list:
return True
sender_str = str(sender_id)
if sender_str in allow_list:
return True
@@ -89,7 +93,8 @@ class BaseChannel(ABC):
chat_id: str,
content: str,
media: list[str] | None = None,
metadata: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
) -> None:
"""
Handle an incoming message from the chat platform.
@@ -102,11 +107,13 @@ class BaseChannel(ABC):
content: Message text content.
media: Optional list of media URLs.
metadata: Optional channel-specific metadata.
session_key: Optional session key override (e.g. thread-scoped sessions).
"""
if not self.is_allowed(sender_id):
logger.warning(
f"Access denied for sender {sender_id} on channel {self.name}. "
f"Add them to allowFrom list in config to grant access."
"Access denied for sender {} on channel {}. "
"Add them to allowFrom list in config to grant access.",
sender_id, self.name,
)
return
@@ -116,7 +123,8 @@ class BaseChannel(ABC):
chat_id=str(chat_id),
content=content,
media=media or [],
metadata=metadata or {}
metadata=metadata or {},
session_key_override=session_key,
)
await self.bus.publish_inbound(msg)
+15 -13
View File
@@ -58,14 +58,15 @@ class NanobotDingTalkHandler(CallbackHandler):
if not content:
logger.warning(
f"Received empty or unsupported message type: {chatbot_msg.message_type}"
"Received empty or unsupported message type: {}",
chatbot_msg.message_type,
)
return AckMessage.STATUS_OK, "OK"
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
# Store reference to prevent GC before task completes.
@@ -78,7 +79,7 @@ class NanobotDingTalkHandler(CallbackHandler):
return AckMessage.STATUS_OK, "OK"
except Exception as e:
logger.error(f"Error processing DingTalk message: {e}")
logger.error("Error processing DingTalk message: {}", e)
# Return OK to avoid retry loop from DingTalk server
return AckMessage.STATUS_OK, "Error"
@@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel):
self._http = httpx.AsyncClient()
logger.info(
f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..."
"Initializing DingTalk Stream Client with Client ID: {}...",
self.config.client_id,
)
credential = Credential(self.config.client_id, self.config.client_secret)
self._client = DingTalkStreamClient(credential)
@@ -142,13 +144,13 @@ class DingTalkChannel(BaseChannel):
try:
await self._client.start()
except Exception as e:
logger.warning(f"DingTalk stream error: {e}")
logger.warning("DingTalk stream error: {}", e)
if self._running:
logger.info("Reconnecting DingTalk stream in 5 seconds...")
await asyncio.sleep(5)
except Exception as e:
logger.exception(f"Failed to start DingTalk channel: {e}")
logger.exception("Failed to start DingTalk channel: {}", e)
async def stop(self) -> None:
"""Stop the DingTalk bot."""
@@ -186,7 +188,7 @@ class DingTalkChannel(BaseChannel):
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
return self._access_token
except Exception as e:
logger.error(f"Failed to get DingTalk access token: {e}")
logger.error("Failed to get DingTalk access token: {}", e)
return None
async def send(self, msg: OutboundMessage) -> None:
@@ -208,7 +210,7 @@ class DingTalkChannel(BaseChannel):
"msgParam": json.dumps({
"text": msg.content,
"title": "Nanobot Reply",
}),
}, ensure_ascii=False),
}
if not self._http:
@@ -218,11 +220,11 @@ class DingTalkChannel(BaseChannel):
try:
resp = await self._http.post(url, json=data, headers=headers)
if resp.status_code != 200:
logger.error(f"DingTalk send failed: {resp.text}")
logger.error("DingTalk send failed: {}", resp.text)
else:
logger.debug(f"DingTalk message sent to {msg.chat_id}")
logger.debug("DingTalk message sent to {}", msg.chat_id)
except Exception as e:
logger.error(f"Error sending DingTalk message: {e}")
logger.error("Error sending DingTalk message: {}", e)
async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
"""Handle incoming message (called by NanobotDingTalkHandler).
@@ -231,7 +233,7 @@ class DingTalkChannel(BaseChannel):
permission checks before publishing to the bus.
"""
try:
logger.info(f"DingTalk inbound: {content} from {sender_name}")
logger.info("DingTalk inbound: {} from {}", content, sender_name)
await self._handle_message(
sender_id=sender_id,
chat_id=sender_id, # For private chat, chat_id == sender_id
@@ -242,4 +244,4 @@ class DingTalkChannel(BaseChannel):
},
)
except Exception as e:
logger.error(f"Error publishing DingTalk message: {e}")
logger.error("Error publishing DingTalk message: {}", e)
+68 -28
View File
@@ -17,6 +17,29 @@ from nanobot.config.schema import DiscordConfig
DISCORD_API_BASE = "https://discord.com/api/v10"
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
MAX_MESSAGE_LEN = 2000 # Discord message character limit
def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
"""Split content into chunks within max_len, preferring line breaks."""
if not content:
return []
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 <= 0:
pos = cut.rfind(' ')
if pos <= 0:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
return chunks
class DiscordChannel(BaseChannel):
@@ -51,7 +74,7 @@ class DiscordChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Discord gateway error: {e}")
logger.warning("Discord gateway error: {}", e)
if self._running:
logger.info("Reconnecting to Discord gateway in 5 seconds...")
await asyncio.sleep(5)
@@ -79,34 +102,48 @@ class DiscordChannel(BaseChannel):
return
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
payload: dict[str, Any] = {"content": msg.content}
if msg.reply_to:
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
headers = {"Authorization": f"Bot {self.config.token}"}
try:
for attempt in range(3):
try:
response = await self._http.post(url, headers=headers, json=payload)
if response.status_code == 429:
data = response.json()
retry_after = float(data.get("retry_after", 1.0))
logger.warning(f"Discord rate limited, retrying in {retry_after}s")
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
return
except Exception as e:
if attempt == 2:
logger.error(f"Error sending Discord message: {e}")
else:
await asyncio.sleep(1)
chunks = _split_message(msg.content or "")
if not chunks:
return
for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk}
# Only set reply reference on the first chunk
if i == 0 and msg.reply_to:
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
if not await self._send_payload(url, headers, payload):
break # Abort remaining chunks on failure
finally:
await self._stop_typing(msg.chat_id)
async def _send_payload(
self, url: str, headers: dict[str, str], payload: dict[str, Any]
) -> bool:
"""Send a single Discord API payload with retry on rate-limit. Returns True on success."""
for attempt in range(3):
try:
response = await self._http.post(url, headers=headers, json=payload)
if response.status_code == 429:
data = response.json()
retry_after = float(data.get("retry_after", 1.0))
logger.warning("Discord rate limited, retrying in {}s", retry_after)
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
return True
except Exception as e:
if attempt == 2:
logger.error("Error sending Discord message: {}", e)
else:
await asyncio.sleep(1)
return False
async def _gateway_loop(self) -> None:
"""Main gateway loop: identify, heartbeat, dispatch events."""
if not self._ws:
@@ -116,7 +153,7 @@ class DiscordChannel(BaseChannel):
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
logger.warning("Invalid JSON from Discord gateway: {}", raw[:100])
continue
op = data.get("op")
@@ -175,7 +212,7 @@ class DiscordChannel(BaseChannel):
try:
await self._ws.send(json.dumps(payload))
except Exception as e:
logger.warning(f"Discord heartbeat failed: {e}")
logger.warning("Discord heartbeat failed: {}", e)
break
await asyncio.sleep(interval_s)
@@ -219,7 +256,7 @@ class DiscordChannel(BaseChannel):
media_paths.append(str(file_path))
content_parts.append(f"[attachment: {file_path}]")
except Exception as e:
logger.warning(f"Failed to download Discord attachment: {e}")
logger.warning("Failed to download Discord attachment: {}", e)
content_parts.append(f"[attachment: {filename} - download failed]")
reply_to = (payload.get("referenced_message") or {}).get("id")
@@ -248,8 +285,11 @@ class DiscordChannel(BaseChannel):
while self._running:
try:
await self._http.post(url, headers=headers)
except Exception:
pass
except asyncio.CancelledError:
return
except Exception as e:
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
return
await asyncio.sleep(8)
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
+14 -9
View File
@@ -94,7 +94,7 @@ class EmailChannel(BaseChannel):
metadata=item.get("metadata", {}),
)
except Exception as e:
logger.error(f"Email polling error: {e}")
logger.error("Email polling error: {}", e)
await asyncio.sleep(poll_seconds)
@@ -108,11 +108,6 @@ class EmailChannel(BaseChannel):
logger.warning("Skip email send: consent_granted is false")
return
force_send = bool((msg.metadata or {}).get("force_send"))
if not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply: auto_reply_enabled is false")
return
if not self.config.smtp_host:
logger.warning("Email channel SMTP host not configured")
return
@@ -122,6 +117,15 @@ class EmailChannel(BaseChannel):
logger.warning("Email channel missing recipient address")
return
# Determine if this is a reply (recipient has sent us an email before)
is_reply = to_addr in self._last_subject_by_chat
force_send = bool((msg.metadata or {}).get("force_send"))
# autoReplyEnabled only controls automatic replies, not proactive sends
if is_reply and not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
return
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
subject = self._reply_subject(base_subject)
if msg.metadata and isinstance(msg.metadata.get("subject"), str):
@@ -143,7 +147,7 @@ class EmailChannel(BaseChannel):
try:
await asyncio.to_thread(self._smtp_send, email_msg)
except Exception as e:
logger.error(f"Error sending email to {to_addr}: {e}")
logger.error("Error sending email to {}: {}", to_addr, e)
raise
def _validate_config(self) -> bool:
@@ -162,7 +166,7 @@ class EmailChannel(BaseChannel):
missing.append("smtp_password")
if missing:
logger.error(f"Email channel not configured, missing: {', '.join(missing)}")
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
return False
return True
@@ -304,7 +308,8 @@ class EmailChannel(BaseChannel):
self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
self._processed_uids.clear()
# Evict a random half to cap memory; mark_seen is the primary dedup
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
+464 -54
View File
@@ -2,9 +2,11 @@
import asyncio
import json
import os
import re
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Any
from loguru import logger
@@ -17,11 +19,17 @@ from nanobot.config.schema import FeishuConfig
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
Emoji,
GetFileRequest,
GetMessageResourceRequest,
P2ImMessageReceiveV1,
)
FEISHU_AVAILABLE = True
@@ -39,6 +47,204 @@ MSG_TYPE_MAP = {
}
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
"""Extract text representation from share cards and interactive messages."""
parts = []
if msg_type == "share_chat":
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
elif msg_type == "share_user":
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
elif msg_type == "interactive":
parts.extend(_extract_interactive_content(content_json))
elif msg_type == "share_calendar_event":
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
elif msg_type == "system":
parts.append("[system message]")
elif msg_type == "merge_forward":
parts.append("[merged forward messages]")
return "\n".join(parts) if parts else f"[{msg_type}]"
def _extract_interactive_content(content: dict) -> list[str]:
"""Recursively extract text and links from interactive card content."""
parts = []
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
return [content] if content.strip() else []
if not isinstance(content, dict):
return parts
if "title" in content:
title = content["title"]
if isinstance(title, dict):
title_content = title.get("content", "") or title.get("text", "")
if title_content:
parts.append(f"title: {title_content}")
elif isinstance(title, str):
parts.append(f"title: {title}")
for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []:
parts.extend(_extract_element_content(element))
card = content.get("card", {})
if card:
parts.extend(_extract_interactive_content(card))
header = content.get("header", {})
if header:
header_title = header.get("title", {})
if isinstance(header_title, dict):
header_text = header_title.get("content", "") or header_title.get("text", "")
if header_text:
parts.append(f"title: {header_text}")
return parts
def _extract_element_content(element: dict) -> list[str]:
"""Extract content from a single card element."""
parts = []
if not isinstance(element, dict):
return parts
tag = element.get("tag", "")
if tag in ("markdown", "lark_md"):
content = element.get("content", "")
if content:
parts.append(content)
elif tag == "div":
text = element.get("text", {})
if isinstance(text, dict):
text_content = text.get("content", "") or text.get("text", "")
if text_content:
parts.append(text_content)
elif isinstance(text, str):
parts.append(text)
for field in element.get("fields", []):
if isinstance(field, dict):
field_text = field.get("text", {})
if isinstance(field_text, dict):
c = field_text.get("content", "")
if c:
parts.append(c)
elif tag == "a":
href = element.get("href", "")
text = element.get("text", "")
if href:
parts.append(f"link: {href}")
if text:
parts.append(text)
elif tag == "button":
text = element.get("text", {})
if isinstance(text, dict):
c = text.get("content", "")
if c:
parts.append(c)
url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
if url:
parts.append(f"link: {url}")
elif tag == "img":
alt = element.get("alt", {})
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
elif tag == "note":
for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne))
elif tag == "column_set":
for col in element.get("columns", []):
for ce in col.get("elements", []):
parts.extend(_extract_element_content(ce))
elif tag == "plain_text":
content = element.get("content", "")
if content:
parts.append(content)
else:
for ne in element.get("elements", []):
parts.extend(_extract_element_content(ne))
return parts
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
"""Extract text and image keys from Feishu post (rich text) message content.
Supports two formats:
1. Direct format: {"title": "...", "content": [...]}
2. Localized format: {"zh_cn": {"title": "...", "content": [...]}}
Returns:
(text, image_keys) - extracted text and list of image keys
"""
def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]:
if not isinstance(lang_content, dict):
return None, []
title = lang_content.get("title", "")
content_blocks = lang_content.get("content", [])
if not isinstance(content_blocks, list):
return None, []
text_parts = []
image_keys = []
if title:
text_parts.append(title)
for block in content_blocks:
if not isinstance(block, list):
continue
for element in block:
if isinstance(element, dict):
tag = element.get("tag")
if tag == "text":
text_parts.append(element.get("text", ""))
elif tag == "a":
text_parts.append(element.get("text", ""))
elif tag == "at":
text_parts.append(f"@{element.get('user_name', 'user')}")
elif tag == "img":
img_key = element.get("image_key")
if img_key:
image_keys.append(img_key)
text = " ".join(text_parts).strip() if text_parts else None
return text, image_keys
# Try direct format first
if "content" in content_json:
text, images = extract_from_lang(content_json)
if text or images:
return text or "", images
# Try localized format
for lang_key in ("zh_cn", "en_us", "ja_jp"):
lang_content = content_json.get(lang_key)
text, images = extract_from_lang(lang_content)
if text or images:
return text or "", images
return "", []
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
class FeishuChannel(BaseChannel):
"""
Feishu/Lark channel using WebSocket long connection.
@@ -104,7 +310,7 @@ class FeishuChannel(BaseChannel):
try:
self._ws_client.start()
except Exception as e:
logger.warning(f"Feishu WebSocket error: {e}")
logger.warning("Feishu WebSocket error: {}", e)
if self._running:
import time; time.sleep(5)
@@ -125,7 +331,7 @@ class FeishuChannel(BaseChannel):
try:
self._ws_client.stop()
except Exception as e:
logger.warning(f"Error stopping WebSocket client: {e}")
logger.warning("Error stopping WebSocket client: {}", e)
logger.info("Feishu bot stopped")
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
@@ -142,11 +348,11 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.create(request)
if not response.success():
logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
else:
logger.debug(f"Added {emoji_type} reaction to message {message_id}")
logger.debug("Added {} reaction to message {}", emoji_type, message_id)
except Exception as e:
logger.warning(f"Error adding reaction: {e}")
logger.warning("Error adding reaction: {}", e)
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
"""
@@ -216,7 +422,6 @@ class FeishuChannel(BaseChannel):
before = protected[last_end:m.start()].strip()
if before:
elements.append({"tag": "markdown", "content": before})
level = len(m.group(1))
text = m.group(2).strip()
elements.append({
"tag": "div",
@@ -237,50 +442,220 @@ class FeishuChannel(BaseChannel):
return elements or [{"tag": "markdown", "content": content}]
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
_AUDIO_EXTS = {".opus"}
_FILE_TYPE_MAP = {
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
}
def _upload_image_sync(self, file_path: str) -> str | None:
"""Upload an image to Feishu and return the image_key."""
try:
with open(file_path, "rb") as f:
request = CreateImageRequest.builder() \
.request_body(
CreateImageRequestBody.builder()
.image_type("message")
.image(f)
.build()
).build()
response = self._client.im.v1.image.create(request)
if response.success():
image_key = response.data.image_key
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
return image_key
else:
logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
logger.error("Error uploading image {}: {}", file_path, e)
return None
def _upload_file_sync(self, file_path: str) -> str | None:
"""Upload a file to Feishu and return the file_key."""
ext = os.path.splitext(file_path)[1].lower()
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
file_name = os.path.basename(file_path)
try:
with open(file_path, "rb") as f:
request = CreateFileRequest.builder() \
.request_body(
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(f)
.build()
).build()
response = self._client.im.v1.file.create(request)
if response.success():
file_key = response.data.file_key
logger.debug("Uploaded file {}: {}", file_name, file_key)
return file_key
else:
logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
logger.error("Error uploading file {}: {}", file_path, e)
return None
def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]:
"""Download an image from Feishu message by message_id and image_key."""
try:
request = GetMessageResourceRequest.builder() \
.message_id(message_id) \
.file_key(image_key) \
.type("image") \
.build()
response = self._client.im.v1.message_resource.get(request)
if response.success():
file_data = response.file
# GetMessageResourceRequest returns BytesIO, need to read bytes
if hasattr(file_data, 'read'):
file_data = file_data.read()
return file_data, response.file_name
else:
logger.error("Failed to download image: code={}, msg={}", response.code, response.msg)
return None, None
except Exception as e:
logger.error("Error downloading image {}: {}", image_key, e)
return None, None
def _download_file_sync(
self, message_id: str, file_key: str, resource_type: str = "file"
) -> tuple[bytes | None, str | None]:
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
try:
request = (
GetMessageResourceRequest.builder()
.message_id(message_id)
.file_key(file_key)
.type(resource_type)
.build()
)
response = self._client.im.v1.message_resource.get(request)
if response.success():
file_data = response.file
if hasattr(file_data, "read"):
file_data = file_data.read()
return file_data, response.file_name
else:
logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg)
return None, None
except Exception:
logger.exception("Error downloading {} {}", resource_type, file_key)
return None, None
async def _download_and_save_media(
self,
msg_type: str,
content_json: dict,
message_id: str | None = None
) -> tuple[str | None, str]:
"""
Download media from Feishu and save to local disk.
Returns:
(file_path, content_text) - file_path is None if download failed
"""
loop = asyncio.get_running_loop()
media_dir = Path.home() / ".nanobot" / "media"
media_dir.mkdir(parents=True, exist_ok=True)
data, filename = None, None
if msg_type == "image":
image_key = content_json.get("image_key")
if image_key and message_id:
data, filename = await loop.run_in_executor(
None, self._download_image_sync, message_id, image_key
)
if not filename:
filename = f"{image_key[:16]}.jpg"
elif msg_type in ("audio", "file", "media"):
file_key = content_json.get("file_key")
if file_key and message_id:
data, filename = await loop.run_in_executor(
None, self._download_file_sync, message_id, file_key, msg_type
)
if not filename:
ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
filename = f"{file_key[:16]}{ext}"
if data and filename:
file_path = media_dir / filename
file_path.write_bytes(data)
logger.debug("Downloaded {} to {}", msg_type, file_path)
return str(file_path), f"[{msg_type}: {filename}]"
return None, f"[{msg_type}: download failed]"
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
"""Send a single message (text/image/file/interactive) synchronously."""
try:
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(msg_type)
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
msg_type, response.code, response.msg, response.get_log_id()
)
return False
logger.debug("Feishu {} message sent to {}", msg_type, receive_id)
return True
except Exception as e:
logger.error("Error sending Feishu {} message: {}", msg_type, e)
return False
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu."""
"""Send a message through Feishu, including media (images/files) if present."""
if not self._client:
logger.warning("Feishu client not initialized")
return
try:
# Determine receive_id_type based on chat_id format
# open_id starts with "ou_", chat_id starts with "oc_"
if msg.chat_id.startswith("oc_"):
receive_id_type = "chat_id"
else:
receive_id_type = "open_id"
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
loop = asyncio.get_running_loop()
# Build card with markdown + table support
elements = self._build_card_elements(msg.content)
card = {
"config": {"wide_screen_mode": True},
"elements": elements,
}
content = json.dumps(card, ensure_ascii=False)
for file_path in msg.media:
if not os.path.isfile(file_path):
logger.warning("Media file not found: {}", file_path)
continue
ext = os.path.splitext(file_path)[1].lower()
if ext in self._IMAGE_EXTS:
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
if key:
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
)
else:
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
if key:
media_type = "audio" if ext in self._AUDIO_EXTS else "file"
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
)
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(msg.chat_id)
.msg_type("interactive")
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
f"Failed to send Feishu message: code={response.code}, "
f"msg={response.msg}, log_id={response.get_log_id()}"
if msg.content and msg.content.strip():
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
)
else:
logger.debug(f"Feishu message sent to {msg.chat_id}")
except Exception as e:
logger.error(f"Error sending Feishu message: {e}")
logger.error("Error sending Feishu message: {}", e)
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
"""
@@ -303,33 +678,67 @@ class FeishuChannel(BaseChannel):
return
self._processed_message_ids[message_id] = None
# Trim cache: keep most recent 500 when exceeds 1000
# Trim cache
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Skip bot messages
sender_type = sender.sender_type
if sender_type == "bot":
if sender.sender_type == "bot":
return
sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
chat_id = message.chat_id
chat_type = message.chat_type # "p2p" or "group"
chat_type = message.chat_type
msg_type = message.message_type
# Add reaction to indicate "seen"
await self._add_reaction(message_id, "THUMBSUP")
# Add reaction
await self._add_reaction(message_id, self.config.react_emoji)
# Parse content
content_parts = []
media_paths = []
try:
content_json = json.loads(message.content) if message.content else {}
except json.JSONDecodeError:
content_json = {}
# Parse message content
if msg_type == "text":
try:
content = json.loads(message.content).get("text", "")
except json.JSONDecodeError:
content = message.content or ""
else:
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
text = content_json.get("text", "")
if text:
content_parts.append(text)
if not content:
elif msg_type == "post":
text, image_keys = _extract_post_content(content_json)
if text:
content_parts.append(text)
# Download images embedded in post
for img_key in image_keys:
file_path, content_text = await self._download_and_save_media(
"image", {"image_key": img_key}, message_id
)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("image", "audio", "file", "media"):
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
# Handle share cards and interactive messages
text = _extract_share_card_content(content_json, msg_type)
if text:
content_parts.append(text)
else:
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
content = "\n".join(content_parts) if content_parts else ""
if not content and not media_paths:
return
# Forward to message bus
@@ -338,6 +747,7 @@ class FeishuChannel(BaseChannel):
sender_id=sender_id,
chat_id=reply_to,
content=content,
media=media_paths,
metadata={
"message_id": message_id,
"chat_type": chat_type,
@@ -346,4 +756,4 @@ class FeishuChannel(BaseChannel):
)
except Exception as e:
logger.error(f"Error processing Feishu message: {e}")
logger.error("Error processing Feishu message: {}", e)
+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
+41 -15
View File
@@ -45,7 +45,7 @@ class ChannelManager:
)
logger.info("Telegram channel enabled")
except ImportError as e:
logger.warning(f"Telegram channel not available: {e}")
logger.warning("Telegram channel not available: {}", e)
# WhatsApp channel
if self.config.channels.whatsapp.enabled:
@@ -56,7 +56,7 @@ class ChannelManager:
)
logger.info("WhatsApp channel enabled")
except ImportError as e:
logger.warning(f"WhatsApp channel not available: {e}")
logger.warning("WhatsApp channel not available: {}", e)
# Discord channel
if self.config.channels.discord.enabled:
@@ -67,7 +67,7 @@ class ChannelManager:
)
logger.info("Discord channel enabled")
except ImportError as e:
logger.warning(f"Discord channel not available: {e}")
logger.warning("Discord channel not available: {}", e)
# Feishu channel
if self.config.channels.feishu.enabled:
@@ -78,7 +78,7 @@ class ChannelManager:
)
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
logger.warning("Feishu channel not available: {}", e)
# Mochat channel
if self.config.channels.mochat.enabled:
@@ -90,7 +90,7 @@ class ChannelManager:
)
logger.info("Mochat channel enabled")
except ImportError as e:
logger.warning(f"Mochat channel not available: {e}")
logger.warning("Mochat channel not available: {}", e)
# DingTalk channel
if self.config.channels.dingtalk.enabled:
@@ -101,7 +101,7 @@ class ChannelManager:
)
logger.info("DingTalk channel enabled")
except ImportError as e:
logger.warning(f"DingTalk channel not available: {e}")
logger.warning("DingTalk channel not available: {}", e)
# Email channel
if self.config.channels.email.enabled:
@@ -112,7 +112,7 @@ class ChannelManager:
)
logger.info("Email channel enabled")
except ImportError as e:
logger.warning(f"Email channel not available: {e}")
logger.warning("Email channel not available: {}", e)
# Slack channel
if self.config.channels.slack.enabled:
@@ -123,7 +123,7 @@ class ChannelManager:
)
logger.info("Slack channel enabled")
except ImportError as e:
logger.warning(f"Slack channel not available: {e}")
logger.warning("Slack channel not available: {}", e)
# QQ channel
if self.config.channels.qq.enabled:
@@ -135,14 +135,31 @@ class ChannelManager:
)
logger.info("QQ channel enabled")
except ImportError as e:
logger.warning(f"QQ channel not available: {e}")
logger.warning("QQ channel not available: {}", e)
# Matrix channel
if self.config.channels.matrix.enabled:
try:
from nanobot.channels.matrix import MatrixChannel
self.channels["matrix"] = MatrixChannel(
self.config.channels.matrix,
self.bus,
)
logger.info("Matrix channel enabled")
except ImportError as e:
logger.warning("Matrix channel not available: {}", e)
def register_channel(self, name: str, channel: BaseChannel) -> None:
"""Register an external channel."""
self.channels[name] = channel
logger.info(f"{name} channel registered")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
try:
await channel.start()
except Exception as e:
logger.error(f"Failed to start channel {name}: {e}")
logger.error("Failed to start channel {}: {}", name, e)
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
@@ -156,7 +173,7 @@ class ChannelManager:
# Start channels
tasks = []
for name, channel in self.channels.items():
logger.info(f"Starting {name} channel...")
logger.info("Starting {} channel...", name)
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
# Wait for all to complete (they should run forever)
@@ -178,9 +195,9 @@ class ChannelManager:
for name, channel in self.channels.items():
try:
await channel.stop()
logger.info(f"Stopped {name} channel")
logger.info("Stopped {} channel", name)
except Exception as e:
logger.error(f"Error stopping {name}: {e}")
logger.error("Error stopping {}: {}", name, e)
async def _dispatch_outbound(self) -> None:
"""Dispatch outbound messages to the appropriate channel."""
@@ -193,14 +210,23 @@ class ChannelManager:
timeout=1.0
)
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
continue
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue
# Resolve any pending correlation (hook request-response)
self.bus.resolve_correlation(msg)
channel = self.channels.get(msg.channel)
if channel:
try:
await channel.send(msg)
except Exception as e:
logger.error(f"Error sending to {msg.channel}: {e}")
logger.error("Error sending to {}: {}", msg.channel, e)
else:
logger.warning(f"Unknown channel: {msg.channel}")
logger.warning("Unknown channel: {}", msg.channel)
except asyncio.TimeoutError:
continue
+682
View File
@@ -0,0 +1,682 @@
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
import asyncio
import logging
import mimetypes
from pathlib import Path
from typing import Any, TypeAlias
from loguru import logger
try:
import nh3
from mistune import create_markdown
from nio import (
AsyncClient, AsyncClientConfig, ContentRepositoryConfigError,
DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse,
RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText,
RoomSendError, RoomTypingError, SyncError, UploadError,
)
from nio.crypto.attachments import decrypt_attachment
from nio.exceptions import EncryptionError
except ImportError as e:
raise ImportError(
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
) from e
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
from nanobot.config.loader import get_data_dir
from nanobot.utils.helpers import safe_filename
TYPING_NOTICE_TIMEOUT_MS = 30_000
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
TYPING_KEEPALIVE_INTERVAL_MS = 20_000
MATRIX_HTML_FORMAT = "org.matrix.custom.html"
_ATTACH_MARKER = "[attachment: {}]"
_ATTACH_TOO_LARGE = "[attachment: {} - too large]"
_ATTACH_FAILED = "[attachment: {} - download failed]"
_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]"
_DEFAULT_ATTACH_NAME = "attachment"
_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"}
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
MATRIX_MARKDOWN = create_markdown(
escape=True,
plugins=["table", "strikethrough", "url", "superscript", "subscript"],
)
MATRIX_ALLOWED_HTML_TAGS = {
"p", "a", "strong", "em", "del", "code", "pre", "blockquote",
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "br", "table", "thead", "tbody", "tr", "th", "td",
"caption", "sup", "sub", "img",
}
MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
"a": {"href"}, "code": {"class"}, "ol": {"start"},
"img": {"src", "alt", "title", "width", "height"},
}
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
"""Filter attribute values to a safe Matrix-compatible subset."""
if tag == "a" and attr == "href":
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
if tag == "img" and attr == "src":
return value if value.lower().startswith("mxc://") else None
if tag == "code" and attr == "class":
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
return " ".join(classes) if classes else None
return value
MATRIX_HTML_CLEANER = nh3.Cleaner(
tags=MATRIX_ALLOWED_HTML_TAGS,
attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES,
attribute_filter=_filter_matrix_html_attribute,
url_schemes=MATRIX_ALLOWED_URL_SCHEMES,
strip_comments=True,
link_rel="noopener noreferrer",
)
def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text."""
try:
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
except Exception:
return None
if not formatted:
return None
# Skip formatted_body for plain <p>text</p> to keep payload minimal.
if formatted.startswith("<p>") and formatted.endswith("</p>"):
inner = formatted[3:-4]
if "<" not in inner and ">" not in inner:
return None
return formatted
def _build_matrix_text_content(text: str) -> dict[str, object]:
"""Build Matrix m.text payload with optional HTML formatted_body."""
content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}}
if html := _render_markdown_html(text):
content["format"] = MATRIX_HTML_FORMAT
content["formatted_body"] = html
return content
class _NioLoguruHandler(logging.Handler):
"""Route matrix-nio stdlib logs into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame, depth = frame.f_back, depth + 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def _configure_nio_logging_bridge() -> None:
"""Bridge matrix-nio logs to Loguru (idempotent)."""
nio_logger = logging.getLogger("nio")
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
nio_logger.handlers = [_NioLoguruHandler()]
nio_logger.propagate = False
class MatrixChannel(BaseChannel):
"""Matrix (Element) channel using long-polling sync."""
name = "matrix"
def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False,
workspace: Path | None = None):
super().__init__(config, bus)
self.client: AsyncClient | None = None
self._sync_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {}
self._restrict_to_workspace = restrict_to_workspace
self._workspace = workspace.expanduser().resolve() if workspace else None
self._server_upload_limit_bytes: int | None = None
self._server_upload_limit_checked = False
async def start(self) -> None:
"""Start Matrix client and begin sync loop."""
self._running = True
_configure_nio_logging_bridge()
store_path = get_data_dir() / "matrix-store"
store_path.mkdir(parents=True, exist_ok=True)
self.client = AsyncClient(
homeserver=self.config.homeserver, user=self.config.user_id,
store_path=store_path,
config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled),
)
self.client.user_id = self.config.user_id
self.client.access_token = self.config.access_token
self.client.device_id = self.config.device_id
self._register_event_callbacks()
self._register_response_callbacks()
if not self.config.e2ee_enabled:
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.")
if self.config.device_id:
try:
self.client.load_store()
except Exception:
logger.exception("Matrix store load failed; restart may replay recent messages.")
else:
logger.warning("Matrix device_id empty; restart may replay recent messages.")
self._sync_task = asyncio.create_task(self._sync_loop())
async def stop(self) -> None:
"""Stop the Matrix channel with graceful sync shutdown."""
self._running = False
for room_id in list(self._typing_tasks):
await self._stop_typing_keepalive(room_id, clear_typing=False)
if self.client:
self.client.stop_sync_forever()
if self._sync_task:
try:
await asyncio.wait_for(asyncio.shield(self._sync_task),
timeout=self.config.sync_stop_grace_seconds)
except (asyncio.TimeoutError, asyncio.CancelledError):
self._sync_task.cancel()
try:
await self._sync_task
except asyncio.CancelledError:
pass
if self.client:
await self.client.close()
def _is_workspace_path_allowed(self, path: Path) -> bool:
"""Check path is inside workspace (when restriction enabled)."""
if not self._restrict_to_workspace or not self._workspace:
return True
try:
path.resolve(strict=False).relative_to(self._workspace)
return True
except ValueError:
return False
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
"""Deduplicate and resolve outbound attachment paths."""
seen: set[str] = set()
candidates: list[Path] = []
for raw in media:
if not isinstance(raw, str) or not raw.strip():
continue
path = Path(raw.strip()).expanduser()
try:
key = str(path.resolve(strict=False))
except OSError:
key = str(path)
if key not in seen:
seen.add(key)
candidates.append(path)
return candidates
@staticmethod
def _build_outbound_attachment_content(
*, filename: str, mime: str, size_bytes: int,
mxc_url: str, encryption_info: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build Matrix content payload for an uploaded file/image/audio/video."""
prefix = mime.split("/")[0]
msgtype = {"image": "m.image", "audio": "m.audio", "video": "m.video"}.get(prefix, "m.file")
content: dict[str, Any] = {
"msgtype": msgtype, "body": filename, "filename": filename,
"info": {"mimetype": mime, "size": size_bytes}, "m.mentions": {},
}
if encryption_info:
content["file"] = {**encryption_info, "url": mxc_url}
else:
content["url"] = mxc_url
return content
def _is_encrypted_room(self, room_id: str) -> bool:
if not self.client:
return False
room = getattr(self.client, "rooms", {}).get(room_id)
return bool(getattr(room, "encrypted", False))
async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None:
"""Send m.room.message with E2EE options."""
if not self.client:
return
kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content}
if self.config.e2ee_enabled:
kwargs["ignore_unverified_devices"] = True
await self.client.room_send(**kwargs)
async def _resolve_server_upload_limit_bytes(self) -> int | None:
"""Query homeserver upload limit once per channel lifecycle."""
if self._server_upload_limit_checked:
return self._server_upload_limit_bytes
self._server_upload_limit_checked = True
if not self.client:
return None
try:
response = await self.client.content_repository_config()
except Exception:
return None
upload_size = getattr(response, "upload_size", None)
if isinstance(upload_size, int) and upload_size > 0:
self._server_upload_limit_bytes = upload_size
return upload_size
return None
async def _effective_media_limit_bytes(self) -> int:
"""min(local config, server advertised) — 0 blocks all uploads."""
local_limit = max(int(self.config.max_media_bytes), 0)
server_limit = await self._resolve_server_upload_limit_bytes()
if server_limit is None:
return local_limit
return min(local_limit, server_limit) if local_limit else 0
async def _upload_and_send_attachment(
self, room_id: str, path: Path, limit_bytes: int,
relates_to: dict[str, Any] | None = None,
) -> str | None:
"""Upload one local file to Matrix and send it as a media message. Returns failure marker or None."""
if not self.client:
return _ATTACH_UPLOAD_FAILED.format(path.name or _DEFAULT_ATTACH_NAME)
resolved = path.expanduser().resolve(strict=False)
filename = safe_filename(resolved.name) or _DEFAULT_ATTACH_NAME
fail = _ATTACH_UPLOAD_FAILED.format(filename)
if not resolved.is_file() or not self._is_workspace_path_allowed(resolved):
return fail
try:
size_bytes = resolved.stat().st_size
except OSError:
return fail
if limit_bytes <= 0 or size_bytes > limit_bytes:
return _ATTACH_TOO_LARGE.format(filename)
mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream"
try:
with resolved.open("rb") as f:
upload_result = await self.client.upload(
f, content_type=mime, filename=filename,
encrypt=self.config.e2ee_enabled and self._is_encrypted_room(room_id),
filesize=size_bytes,
)
except Exception:
return fail
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None
if isinstance(upload_response, UploadError):
return fail
mxc_url = getattr(upload_response, "content_uri", None)
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
return fail
content = self._build_outbound_attachment_content(
filename=filename, mime=mime, size_bytes=size_bytes,
mxc_url=mxc_url, encryption_info=encryption_info,
)
if relates_to:
content["m.relates_to"] = relates_to
try:
await self._send_room_content(room_id, content)
except Exception:
return fail
return None
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound content; clear typing for non-progress messages."""
if not self.client:
return
text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = bool((msg.metadata or {}).get("_progress"))
try:
failures: list[str] = []
if candidates:
limit_bytes = await self._effective_media_limit_bytes()
for path in candidates:
if fail := await self._upload_and_send_attachment(
msg.chat_id, path, limit_bytes, relates_to):
failures.append(fail)
if failures:
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
if text or not candidates:
content = _build_matrix_text_content(text)
if relates_to:
content["m.relates_to"] = relates_to
await self._send_room_content(msg.chat_id, content)
finally:
if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
def _register_event_callbacks(self) -> None:
self.client.add_event_callback(self._on_message, RoomMessageText)
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
self.client.add_event_callback(self._on_room_invite, InviteEvent)
def _register_response_callbacks(self) -> None:
self.client.add_response_callback(self._on_sync_error, SyncError)
self.client.add_response_callback(self._on_join_error, JoinError)
self.client.add_response_callback(self._on_send_error, RoomSendError)
def _log_response_error(self, label: str, response: Any) -> None:
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
code = getattr(response, "status_code", None)
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
is_fatal = is_auth or getattr(response, "soft_logout", False)
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
async def _on_sync_error(self, response: SyncError) -> None:
self._log_response_error("sync", response)
async def _on_join_error(self, response: JoinError) -> None:
self._log_response_error("join", response)
async def _on_send_error(self, response: RoomSendError) -> None:
self._log_response_error("send", response)
async def _set_typing(self, room_id: str, typing: bool) -> None:
"""Best-effort typing indicator update."""
if not self.client:
return
try:
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
timeout=TYPING_NOTICE_TIMEOUT_MS)
if isinstance(response, RoomTypingError):
logger.debug("Matrix typing failed for {}: {}", room_id, response)
except Exception:
pass
async def _start_typing_keepalive(self, room_id: str) -> None:
"""Start periodic typing refresh (spec-recommended keepalive)."""
await self._stop_typing_keepalive(room_id, clear_typing=False)
await self._set_typing(room_id, True)
if not self._running:
return
async def loop() -> None:
try:
while self._running:
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
await self._set_typing(room_id, True)
except asyncio.CancelledError:
pass
self._typing_tasks[room_id] = asyncio.create_task(loop())
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
if task := self._typing_tasks.pop(room_id, None):
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if clear_typing:
await self._set_typing(room_id, False)
async def _sync_loop(self) -> None:
while self._running:
try:
await self.client.sync_forever(timeout=30000, full_state=True)
except asyncio.CancelledError:
break
except Exception:
await asyncio.sleep(2)
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
allow_from = self.config.allow_from or []
if not allow_from or event.sender in allow_from:
await self.client.join(room.room_id)
def _is_direct_room(self, room: MatrixRoom) -> bool:
count = getattr(room, "member_count", None)
return isinstance(count, int) and count <= 2
def _is_bot_mentioned(self, event: RoomMessage) -> bool:
"""Check m.mentions payload for bot mention."""
source = getattr(event, "source", None)
if not isinstance(source, dict):
return False
mentions = (source.get("content") or {}).get("m.mentions")
if not isinstance(mentions, dict):
return False
user_ids = mentions.get("user_ids")
if isinstance(user_ids, list) and self.config.user_id in user_ids:
return True
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
"""Apply sender and room policy checks."""
if not self.is_allowed(event.sender):
return False
if self._is_direct_room(room):
return True
policy = self.config.group_policy
if policy == "open":
return True
if policy == "allowlist":
return room.room_id in (self.config.group_allow_from or [])
if policy == "mention":
return self._is_bot_mentioned(event)
return False
def _media_dir(self) -> Path:
d = get_data_dir() / "media" / "matrix"
d.mkdir(parents=True, exist_ok=True)
return d
@staticmethod
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
source = getattr(event, "source", None)
if not isinstance(source, dict):
return {}
content = source.get("content")
return content if isinstance(content, dict) else {}
def _event_thread_root_id(self, event: RoomMessage) -> str | None:
relates_to = self._event_source_content(event).get("m.relates_to")
if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread":
return None
root_id = relates_to.get("event_id")
return root_id if isinstance(root_id, str) and root_id else None
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
if not (root_id := self._event_thread_root_id(event)):
return None
meta: dict[str, str] = {"thread_root_event_id": root_id}
if isinstance(reply_to := getattr(event, "event_id", None), str) and reply_to:
meta["thread_reply_to_event_id"] = reply_to
return meta
@staticmethod
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
if not metadata:
return None
root_id = metadata.get("thread_root_event_id")
if not isinstance(root_id, str) or not root_id:
return None
reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id")
if not isinstance(reply_to, str) or not reply_to:
return None
return {"rel_type": "m.thread", "event_id": root_id,
"m.in_reply_to": {"event_id": reply_to}, "is_falling_back": True}
def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
msgtype = self._event_source_content(event).get("msgtype")
return _MSGTYPE_MAP.get(msgtype, "file")
@staticmethod
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
return (isinstance(getattr(event, "key", None), dict)
and isinstance(getattr(event, "hashes", None), dict)
and isinstance(getattr(event, "iv", None), str))
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
info = self._event_source_content(event).get("info")
size = info.get("size") if isinstance(info, dict) else None
return size if isinstance(size, int) and size >= 0 else None
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
info = self._event_source_content(event).get("info")
if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m:
return m
m = getattr(event, "mimetype", None)
return m if isinstance(m, str) and m else None
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
body = getattr(event, "body", None)
if isinstance(body, str) and body.strip():
if candidate := safe_filename(Path(body).name):
return candidate
return _DEFAULT_ATTACH_NAME if attachment_type == "file" else attachment_type
def _build_attachment_path(self, event: MatrixMediaEvent, attachment_type: str,
filename: str, mime: str | None) -> Path:
safe_name = safe_filename(Path(filename).name) or _DEFAULT_ATTACH_NAME
suffix = Path(safe_name).suffix
if not suffix and mime:
if guessed := mimetypes.guess_extension(mime, strict=False):
safe_name, suffix = f"{safe_name}{guessed}", guessed
stem = (Path(safe_name).stem or attachment_type)[:72]
suffix = suffix[:16]
event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$"))
event_prefix = (event_id[:24] or "evt").strip("_")
return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
async def _download_media_bytes(self, mxc_url: str) -> bytes | None:
if not self.client:
return None
response = await self.client.download(mxc=mxc_url)
if isinstance(response, DownloadError):
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
return None
body = getattr(response, "body", None)
if isinstance(body, (bytes, bytearray)):
return bytes(body)
if isinstance(response, MemoryDownloadResponse):
return bytes(response.body)
if isinstance(body, (str, Path)):
path = Path(body)
if path.is_file():
try:
return path.read_bytes()
except OSError:
return None
return None
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
key = key_obj.get("k") if isinstance(key_obj, dict) else None
sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None
if not all(isinstance(v, str) for v in (key, sha256, iv)):
return None
try:
return decrypt_attachment(ciphertext, key, sha256, iv)
except (EncryptionError, ValueError, TypeError):
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", ""))
return None
async def _fetch_media_attachment(
self, room: MatrixRoom, event: MatrixMediaEvent,
) -> tuple[dict[str, Any] | None, str]:
"""Download, decrypt if needed, and persist a Matrix attachment."""
atype = self._event_attachment_type(event)
mime = self._event_mime(event)
filename = self._event_filename(event, atype)
mxc_url = getattr(event, "url", None)
fail = _ATTACH_FAILED.format(filename)
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
return None, fail
limit_bytes = await self._effective_media_limit_bytes()
declared = self._event_declared_size_bytes(event)
if declared is not None and declared > limit_bytes:
return None, _ATTACH_TOO_LARGE.format(filename)
downloaded = await self._download_media_bytes(mxc_url)
if downloaded is None:
return None, fail
encrypted = self._is_encrypted_media_event(event)
data = downloaded
if encrypted:
if (data := self._decrypt_media_bytes(event, downloaded)) is None:
return None, fail
if len(data) > limit_bytes:
return None, _ATTACH_TOO_LARGE.format(filename)
path = self._build_attachment_path(event, atype, filename, mime)
try:
path.write_bytes(data)
except OSError:
return None, fail
attachment = {
"type": atype, "mime": mime, "filename": filename,
"event_id": str(getattr(event, "event_id", "") or ""),
"encrypted": encrypted, "size_bytes": len(data),
"path": str(path), "mxc_url": mxc_url,
}
return attachment, _ATTACH_MARKER.format(path)
def _base_metadata(self, room: MatrixRoom, event: RoomMessage) -> dict[str, Any]:
"""Build common metadata for text and media handlers."""
meta: dict[str, Any] = {"room": getattr(room, "display_name", room.room_id)}
if isinstance(eid := getattr(event, "event_id", None), str) and eid:
meta["event_id"] = eid
if thread := self._thread_metadata(event):
meta.update(thread)
return meta
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if event.sender == self.config.user_id or not self._should_process_message(room, event):
return
await self._start_typing_keepalive(room.room_id)
try:
await self._handle_message(
sender_id=event.sender, chat_id=room.room_id,
content=event.body, metadata=self._base_metadata(room, event),
)
except Exception:
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
raise
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
if event.sender == self.config.user_id or not self._should_process_message(room, event):
return
attachment, marker = await self._fetch_media_attachment(room, event)
parts: list[str] = []
if isinstance(body := getattr(event, "body", None), str) and body.strip():
parts.append(body.strip())
parts.append(marker)
await self._start_typing_keepalive(room.room_id)
try:
meta = self._base_metadata(room, event)
if attachment:
meta["attachments"] = [attachment]
await self._handle_message(
sender_id=event.sender, chat_id=room.room_id,
content="\n".join(parts),
media=[attachment["path"]] if attachment else [],
metadata=meta,
)
except Exception:
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
raise
+12 -12
View File
@@ -322,7 +322,7 @@ class MochatChannel(BaseChannel):
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
content, msg.reply_to)
except Exception as e:
logger.error(f"Failed to send Mochat message: {e}")
logger.error("Failed to send Mochat message: {}", e)
# ---- config / init helpers ---------------------------------------------
@@ -380,7 +380,7 @@ class MochatChannel(BaseChannel):
@client.event
async def connect_error(data: Any) -> None:
logger.error(f"Mochat websocket connect error: {data}")
logger.error("Mochat websocket connect error: {}", data)
@client.on("claw.session.events")
async def on_session_events(payload: dict[str, Any]) -> None:
@@ -407,7 +407,7 @@ class MochatChannel(BaseChannel):
)
return True
except Exception as e:
logger.error(f"Failed to connect Mochat websocket: {e}")
logger.error("Failed to connect Mochat websocket: {}", e)
try:
await client.disconnect()
except Exception:
@@ -444,7 +444,7 @@ class MochatChannel(BaseChannel):
"limit": self.config.watch_limit,
})
if not ack.get("result"):
logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
return False
data = ack.get("data")
@@ -466,7 +466,7 @@ class MochatChannel(BaseChannel):
return True
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
if not ack.get("result"):
logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
return False
return True
@@ -488,7 +488,7 @@ class MochatChannel(BaseChannel):
try:
await self._refresh_targets(subscribe_new=self._ws_ready)
except Exception as e:
logger.warning(f"Mochat refresh failed: {e}")
logger.warning("Mochat refresh failed: {}", e)
if self._fallback_mode:
await self._ensure_fallback_workers()
@@ -502,7 +502,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/sessions/list", {})
except Exception as e:
logger.warning(f"Mochat listSessions failed: {e}")
logger.warning("Mochat listSessions failed: {}", e)
return
sessions = response.get("sessions")
@@ -536,7 +536,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/groups/get", {})
except Exception as e:
logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
return
raw_panels = response.get("panels")
@@ -598,7 +598,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Mochat watch fallback error ({session_id}): {e}")
logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
async def _panel_poll_worker(self, panel_id: str) -> None:
@@ -625,7 +625,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
await asyncio.sleep(sleep_s)
# ---- inbound event processing ------------------------------------------
@@ -836,7 +836,7 @@ class MochatChannel(BaseChannel):
try:
data = json.loads(self._cursor_path.read_text("utf-8"))
except Exception as e:
logger.warning(f"Failed to read Mochat cursor file: {e}")
logger.warning("Failed to read Mochat cursor file: {}", e)
return
cursors = data.get("cursors") if isinstance(data, dict) else None
if isinstance(cursors, dict):
@@ -852,7 +852,7 @@ class MochatChannel(BaseChannel):
"cursors": self._session_cursor,
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
except Exception as e:
logger.warning(f"Failed to save Mochat cursor file: {e}")
logger.warning("Failed to save Mochat cursor file: {}", e)
# ---- HTTP helpers ------------------------------------------------------
+9 -11
View File
@@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
super().__init__(intents=intents)
async def on_ready(self):
logger.info(f"QQ bot ready: {self.robot.name}")
logger.info("QQ bot ready: {}", self.robot.name)
async def on_c2c_message_create(self, message: "C2CMessage"):
await channel._on_message(message)
@@ -55,7 +55,6 @@ class QQChannel(BaseChannel):
self.config: QQConfig = config
self._client: "botpy.Client | None" = None
self._processed_ids: deque = deque(maxlen=1000)
self._bot_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the QQ bot."""
@@ -71,8 +70,8 @@ class QQChannel(BaseChannel):
BotClass = _make_bot_class(self)
self._client = BotClass()
self._bot_task = asyncio.create_task(self._run_bot())
logger.info("QQ bot started (C2C private message)")
await self._run_bot()
async def _run_bot(self) -> None:
"""Run the bot connection with auto-reconnect."""
@@ -80,7 +79,7 @@ class QQChannel(BaseChannel):
try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e:
logger.warning(f"QQ bot error: {e}")
logger.warning("QQ bot error: {}", e)
if self._running:
logger.info("Reconnecting QQ bot in 5 seconds...")
await asyncio.sleep(5)
@@ -88,11 +87,10 @@ class QQChannel(BaseChannel):
async def stop(self) -> None:
"""Stop the QQ bot."""
self._running = False
if self._bot_task:
self._bot_task.cancel()
if self._client:
try:
await self._bot_task
except asyncio.CancelledError:
await self._client.close()
except Exception:
pass
logger.info("QQ bot stopped")
@@ -108,7 +106,7 @@ class QQChannel(BaseChannel):
content=msg.content,
)
except Exception as e:
logger.error(f"Error sending QQ message: {e}")
logger.error("Error sending QQ message: {}", e)
async def _on_message(self, data: "C2CMessage") -> None:
"""Handle incoming message from QQ."""
@@ -130,5 +128,5 @@ class QQChannel(BaseChannel):
content=content,
metadata={"message_id": data.id},
)
except Exception as e:
logger.error(f"Error handling QQ message: {e}")
except Exception:
logger.exception("Error handling QQ message")
+101 -25
View File
@@ -10,6 +10,8 @@ from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -34,7 +36,7 @@ class SlackChannel(BaseChannel):
logger.error("Slack bot/app token not configured")
return
if self.config.mode != "socket":
logger.error(f"Unsupported Slack mode: {self.config.mode}")
logger.error("Unsupported Slack mode: {}", self.config.mode)
return
self._running = True
@@ -51,9 +53,9 @@ class SlackChannel(BaseChannel):
try:
auth = await self._web_client.auth_test()
self._bot_user_id = auth.get("user_id")
logger.info(f"Slack bot connected as {self._bot_user_id}")
logger.info("Slack bot connected as {}", self._bot_user_id)
except Exception as e:
logger.warning(f"Slack auth_test failed: {e}")
logger.warning("Slack auth_test failed: {}", e)
logger.info("Starting Slack Socket Mode client...")
await self._socket_client.connect()
@@ -68,7 +70,7 @@ class SlackChannel(BaseChannel):
try:
await self._socket_client.close()
except Exception as e:
logger.warning(f"Slack socket close failed: {e}")
logger.warning("Slack socket close failed: {}", e)
self._socket_client = None
async def send(self, msg: OutboundMessage) -> None:
@@ -82,13 +84,26 @@ class SlackChannel(BaseChannel):
channel_type = slack_meta.get("channel_type")
# Only reply in thread for channel/group messages; DMs don't use threads
use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage(
channel=msg.chat_id,
text=msg.content or "",
thread_ts=thread_ts if use_thread else None,
)
thread_ts_param = thread_ts if use_thread else None
if msg.content:
await self._web_client.chat_postMessage(
channel=msg.chat_id,
text=self._to_mrkdwn(msg.content),
thread_ts=thread_ts_param,
)
for media_path in msg.media or []:
try:
await self._web_client.files_upload_v2(
channel=msg.chat_id,
file=media_path,
thread_ts=thread_ts_param,
)
except Exception as e:
logger.error("Failed to upload file {}: {}", media_path, e)
except Exception as e:
logger.error(f"Error sending Slack message: {e}")
logger.error("Error sending Slack message: {}", e)
async def _on_socket_request(
self,
@@ -150,30 +165,39 @@ class SlackChannel(BaseChannel):
text = self._strip_bot_mention(text)
thread_ts = event.get("thread_ts") or event.get("ts")
thread_ts = event.get("thread_ts")
if self.config.reply_in_thread and not thread_ts:
thread_ts = event.get("ts")
# Add :eyes: reaction to the triggering message (best-effort)
try:
if self._web_client and event.get("ts"):
await self._web_client.reactions_add(
channel=chat_id,
name="eyes",
name=self.config.react_emoji,
timestamp=event.get("ts"),
)
except Exception as e:
logger.debug(f"Slack reactions_add failed: {e}")
logger.debug("Slack reactions_add failed: {}", e)
await self._handle_message(
sender_id=sender_id,
chat_id=chat_id,
content=text,
metadata={
"slack": {
"event": event,
"thread_ts": thread_ts,
"channel_type": channel_type,
}
},
)
# Thread-scoped session key for channel/group messages
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
try:
await self._handle_message(
sender_id=sender_id,
chat_id=chat_id,
content=text,
metadata={
"slack": {
"event": event,
"thread_ts": thread_ts,
"channel_type": channel_type,
},
},
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack message from {}", sender_id)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im":
@@ -203,3 +227,55 @@ class SlackChannel(BaseChannel):
if not text or not self._bot_user_id:
return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
_TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```")
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_LEFTOVER_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_LEFTOVER_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
_BARE_URL_RE = re.compile(r"(?<![|<])(https?://\S+)")
@classmethod
def _to_mrkdwn(cls, text: str) -> str:
"""Convert Markdown to Slack mrkdwn, including tables."""
if not text:
return ""
text = cls._TABLE_RE.sub(cls._convert_table, text)
return cls._fixup_mrkdwn(slackify_markdown(text))
@classmethod
def _fixup_mrkdwn(cls, text: str) -> str:
"""Fix markdown artifacts that slackify_markdown misses."""
code_blocks: list[str] = []
def _save_code(m: re.Match) -> str:
code_blocks.append(m.group(0))
return f"\x00CB{len(code_blocks) - 1}\x00"
text = cls._CODE_FENCE_RE.sub(_save_code, text)
text = cls._INLINE_CODE_RE.sub(_save_code, text)
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&amp;", "&"), text)
for i, block in enumerate(code_blocks):
text = text.replace(f"\x00CB{i}\x00", block)
return text
@staticmethod
def _convert_table(match: re.Match) -> str:
"""Convert a Markdown table to a Slack-readable list."""
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
if len(lines) < 2:
return match.group(0)
headers = [h.strip() for h in lines[0].strip("|").split("|")]
start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1
rows: list[str] = []
for line in lines[start:]:
cells = [c.strip() for c in line.strip("|").split("|")]
cells = (cells + [""] * len(headers))[: len(headers)]
parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]]
if parts:
rows.append(" · ".join(parts))
return "\n".join(rows)
+221 -9
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import asyncio
import re
from pathlib import Path
from loguru import logger
from telegram import BotCommand, Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
@@ -92,6 +94,7 @@ class TelegramChannel(BaseChannel):
BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"),
BotCommand("quota", "Show current quota status"),
]
def __init__(
@@ -127,6 +130,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("help", self._forward_command))
self._app.add_handler(CommandHandler("quota", self._forward_command))
# Add message handler for text, photos, voice, documents
self._app.add_handler(
@@ -187,29 +191,237 @@ class TelegramChannel(BaseChannel):
# Stop typing indicator for this chat
self._stop_typing(msg.chat_id)
# Check for suppression
if msg.metadata.get("suppressed", False):
logger.debug(f"Suppressed output (not sent to Telegram): {msg.content[:100]}...")
return # Don't send to Telegram API
try:
# chat_id should be the Telegram chat ID (integer)
chat_id = int(msg.chat_id)
# Convert markdown to Telegram HTML
html_content = _markdown_to_telegram_html(msg.content)
await self._app.bot.send_message(
chat_id=chat_id,
text=html_content,
parse_mode="HTML"
)
# Check if message has media attachments
if msg.media:
await self._send_with_media(chat_id, html_content, msg.media)
else:
# Text-only message - split if too long
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
except ValueError:
logger.error(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._app.bot.send_message(
chat_id=int(msg.chat_id),
text=msg.content
)
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
# Group media for album sending
media_items = [(path, kind) for path, kind, _, _ in processed_media]
grouping = group_media_for_album(media_items)
# Handle caption length (Telegram limit: 1024 chars)
if len(caption) > 1024:
# Send media without caption, then follow-up text
media_caption = None
followup_text = caption
else:
media_caption = caption
followup_text = None
# Send album if grouped
if grouping["album"]:
album_paths = grouping["album"]
album_media = []
for path, kind, content, filename in processed_media:
if path not in album_paths:
continue
if kind == MediaKind.IMAGE:
media_obj = InputMediaPhoto(
media=content,
caption=media_caption if len(album_media) == 0 else None,
parse_mode="HTML" if media_caption else None
)
elif kind == MediaKind.VIDEO:
media_obj = InputMediaVideo(
media=content,
caption=media_caption if len(album_media) == 0 else None,
parse_mode="HTML" if media_caption else None
)
else:
continue # Skip non-album types
album_media.append(media_obj)
if album_media:
await self._app.bot.send_media_group(
chat_id=chat_id,
media=album_media
)
# Send separate media
for i, (path, kind, content, filename) in enumerate(processed_media):
if path in grouping["album"]:
continue # Already sent in album
# Only first separate item gets caption
item_caption = media_caption if i == 0 else None
if kind == MediaKind.IMAGE:
await self._app.bot.send_photo(
chat_id=chat_id,
photo=content,
caption=item_caption,
parse_mode="HTML" if item_caption else None
)
elif kind == MediaKind.VIDEO:
await self._app.bot.send_video(
chat_id=chat_id,
video=content,
caption=item_caption,
parse_mode="HTML" if item_caption else None
)
elif kind == MediaKind.AUDIO:
await self._app.bot.send_audio(
chat_id=chat_id,
audio=content,
caption=item_caption,
parse_mode="HTML" if item_caption else None,
filename=filename
)
elif kind == MediaKind.DOCUMENT:
await self._app.bot.send_document(
chat_id=chat_id,
document=content,
caption=item_caption,
parse_mode="HTML" if item_caption else None,
filename=filename
)
# Send follow-up text if caption was too long
if followup_text:
await self._app.bot.send_message(
chat_id=chat_id,
text=followup_text,
parse_mode="HTML"
)
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
if not update.message or not update.effective_user:
+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]
}
+10 -10
View File
@@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel):
bridge_url = self.config.bridge_url
logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...")
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True
@@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel):
try:
await self._handle_bridge_message(message)
except Exception as e:
logger.error(f"Error handling bridge message: {e}")
logger.error("Error handling bridge message: {}", e)
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._ws = None
logger.warning(f"WhatsApp bridge connection error: {e}")
logger.warning("WhatsApp bridge connection error: {}", e)
if self._running:
logger.info("Reconnecting in 5 seconds...")
@@ -87,16 +87,16 @@ class WhatsAppChannel(BaseChannel):
"to": msg.chat_id,
"text": msg.content
}
await self._ws.send(json.dumps(payload))
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error(f"Error sending WhatsApp message: {e}")
logger.error("Error sending WhatsApp message: {}", e)
async def _handle_bridge_message(self, raw: str) -> None:
"""Handle a message from the bridge."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON from bridge: {raw[:100]}")
logger.warning("Invalid JSON from bridge: {}", raw[:100])
return
msg_type = data.get("type")
@@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel):
# Extract just the phone number or lid as chat_id
user_id = pn if pn else sender
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
logger.info(f"Sender {sender}")
logger.info("Sender {}", sender)
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.")
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
await self._handle_message(
@@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "status":
# Connection status update
status = data.get("status")
logger.info(f"WhatsApp status: {status}")
logger.info("WhatsApp status: {}", status)
if status == "connected":
self._connected = True
@@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel):
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error":
logger.error(f"WhatsApp bridge error: {data.get('error')}")
logger.error("WhatsApp bridge error: {}", data.get('error'))
+373 -148
View File
@@ -19,6 +19,8 @@ from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
app = typer.Typer(
name="nanobot",
@@ -155,7 +157,7 @@ def main(
@app.command()
def onboard():
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, save_config
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
@@ -163,20 +165,28 @@ def onboard():
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
if not typer.confirm("Overwrite?"):
raise typer.Exit()
# Create default config
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Created config at {config_path}")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
if typer.confirm("Overwrite?"):
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
else:
config = load_config()
save_config(config)
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
_create_workspace_templates(workspace)
if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
sync_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
@@ -188,102 +198,25 @@ def onboard():
def _create_workspace_templates(workspace: Path):
"""Create default workspace template files."""
templates = {
"AGENTS.md": """# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
def _make_provider(config: Config):
"""Create LLM provider from config. Uses OAuth for subscription tokens."""
from nanobot.providers import create_provider
## Guidelines
- Always explain what you're doing before taking actions
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
- Remember important information in memory/MEMORY.md; past events are logged in memory/HISTORY.md
""",
"SOUL.md": """# Soul
I am nanobot, a lightweight AI assistant.
## Personality
- Helpful and friendly
- Concise and to the point
- Curious and eager to learn
## Values
- Accuracy over speed
- User privacy and safety
- Transparency in actions
""",
"USER.md": """# User
Information about the user goes here.
## Preferences
- Communication style: (casual/formal)
- Timezone: (your timezone)
- Language: (your preferred language)
""",
}
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists():
memory_file.write_text("""# Long-term Memory
This file stores important information that should persist across sessions.
## User Information
(Important facts about the user)
## Preferences
(User preferences learned over time)
## Important Notes
(Things to remember)
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("")
console.print(" [dim]Created memory/HISTORY.md[/dim]")
# Create skills directory for custom user skills
skills_dir = workspace / "skills"
skills_dir.mkdir(exist_ok=True)
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
return LiteLLMProvider(
api_key=p.api_key if p else None,
return create_provider(
api_key=p.api_key if p else "",
model=model,
api_base=config.get_api_base(),
default_model=model,
extra_headers=p.extra_headers if p else None,
provider_name=config.get_provider_name(),
thinking_budget=config.agents.defaults.thinking_budget,
)
@@ -314,6 +247,7 @@ def gateway(
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
@@ -328,13 +262,18 @@ def gateway(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
)
# Set cron callback (needs agent)
@@ -356,21 +295,60 @@ def gateway(
return response
cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True
)
# Create channel manager
channels = ChannelManager(config, bus)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
enabled = set(channels.enabled_channels)
# Prefer the most recently updated non-internal session on an enabled channel.
for item in session_manager.list_sessions():
key = item.get("key") or ""
if ":" not in key:
continue
channel, chat_id = key.split(":", 1)
if channel in {"cli", "system"}:
continue
if channel in enabled and chat_id:
return channel, chat_id
# Fallback keeps prior behavior but remains explicit.
return "cli", "direct"
# Create heartbeat service
async def on_heartbeat_execute(tasks: str) -> str:
"""Phase 2: execute heartbeat tasks through the full agent loop."""
channel, chat_id = _pick_heartbeat_target()
async def _silent(*_args, **_kwargs):
pass
return await agent.process_direct(
tasks,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
async def on_heartbeat_notify(response: str) -> None:
"""Deliver a heartbeat response to the user's channel."""
from nanobot.bus.events import OutboundMessage
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return # No external channel available to deliver to
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
provider=provider,
model=agent.model,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
interval_s=hb_cfg.interval_s,
enabled=hb_cfg.enabled,
)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
@@ -380,7 +358,7 @@ def gateway(
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m")
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
async def run():
try:
@@ -392,6 +370,8 @@ def gateway(
)
except KeyboardInterrupt:
console.print("\nShutting down...")
finally:
await agent.close_mcp()
heartbeat.stop()
cron.stop()
agent.stop()
@@ -415,16 +395,22 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger
config = load_config()
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
# Create cron service for tool usage (no callback needed for CLI unless running)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
if logs:
logger.enable("nanobot")
else:
@@ -435,11 +421,17 @@ def agent(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
@@ -450,19 +442,34 @@ def agent(
# Animated spinner is safe to use with prompt_toolkit input handling
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
ch = agent_loop.channels_config
if ch and tool_hint and not ch.send_tool_hints:
return
if ch and not tool_hint and not ch.send_progress:
return
console.print(f" [dim]↳ {content}[/dim]")
if message:
# Single message mode
# Single message mode — direct call, no bus needed
async def run_once():
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown)
await agent_loop.close_mcp()
asyncio.run(run_once())
else:
# Interactive mode
# Interactive mode — route through bus like other channels
from nanobot.bus.events import InboundMessage
_init_prompt_session()
console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
if ":" in session_id:
cli_channel, cli_chat_id = session_id.split(":", 1)
else:
cli_channel, cli_chat_id = "cli", session_id
def _exit_on_sigint(signum, frame):
_restore_terminal()
console.print("\nGoodbye!")
@@ -471,30 +478,80 @@ def agent(
signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
while True:
try:
_flush_pending_tty_input()
user_input = await _read_interactive_input_async()
command = user_input.strip()
if not command:
continue
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[str] = []
if _is_exit_command(command):
async def _consume_outbound():
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
if msg.metadata.get("_progress"):
is_tool_hint = msg.metadata.get("_tool_hint", False)
ch = agent_loop.channels_config
if ch and is_tool_hint and not ch.send_tool_hints:
pass
elif ch and not is_tool_hint and not ch.send_progress:
pass
else:
console.print(f" [dim]↳ {msg.content}[/dim]")
elif not turn_done.is_set():
if msg.content:
turn_response.append(msg.content)
turn_done.set()
elif msg.content:
console.print()
_print_agent_response(msg.content, render_markdown=markdown)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
outbound_task = asyncio.create_task(_consume_outbound())
try:
while True:
try:
_flush_pending_tty_input()
user_input = await _read_interactive_input_async()
command = user_input.strip()
if not command:
continue
if _is_exit_command(command):
_restore_terminal()
console.print("\nGoodbye!")
break
turn_done.clear()
turn_response.clear()
await bus.publish_inbound(InboundMessage(
channel=cli_channel,
sender_id="user",
chat_id=cli_chat_id,
content=user_input,
))
with _thinking_ctx():
await turn_done.wait()
if turn_response:
_print_agent_response(turn_response[0], render_markdown=markdown)
except KeyboardInterrupt:
_restore_terminal()
console.print("\nGoodbye!")
break
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
_restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
_restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
_restore_terminal()
console.print("\nGoodbye!")
break
finally:
agent_loop.stop()
outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp()
asyncio.run(run_interactive())
@@ -571,6 +628,33 @@ def channels_status():
slack_config
)
# DingTalk
dt = config.channels.dingtalk
dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]"
table.add_row(
"DingTalk",
"" if dt.enabled else "",
dt_config
)
# QQ
qq = config.channels.qq
qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]"
table.add_row(
"QQ",
"" if qq.enabled else "",
qq_config
)
# Email
em = config.channels.email
em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]"
table.add_row(
"Email",
"" if em.enabled else "",
em_config
)
console.print(table)
@@ -689,20 +773,26 @@ def cron_list(
table.add_column("Next Run")
import time
from datetime import datetime as _dt
from zoneinfo import ZoneInfo
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
elif job.schedule.kind == "cron":
sched = job.schedule.expr or ""
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
next_run = next_time
ts = job.state.next_run_at_ms / 1000
try:
tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
@@ -717,6 +807,7 @@ def cron_add(
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
@@ -727,11 +818,15 @@ def cron_add(
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
if tz and not cron_expr:
console.print("[red]Error: --tz can only be used with --cron[/red]")
raise typer.Exit(1)
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr)
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
import datetime
dt = datetime.datetime.fromisoformat(at)
@@ -743,14 +838,18 @@ def cron_add(
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.add_job(
name=name,
schedule=schedule,
message=message,
deliver=deliver,
to=to,
channel=channel,
)
try:
job = service.add_job(
name=name,
schedule=schedule,
message=message,
deliver=deliver,
to=to,
channel=channel,
)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@@ -798,17 +897,58 @@ def cron_run(
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from loguru import logger
from nanobot.config.loader import load_config, get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
logger.disable("nanobot")
config = load_config()
provider = _make_provider(config)
bus = MessageBus()
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
)
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
result_holder = []
async def on_job(job: CronJob) -> str | None:
response = await agent_loop.process_direct(
job.payload.message,
session_key=f"cron:{job.id}",
channel=job.payload.channel or "cli",
chat_id=job.payload.to or "direct",
)
result_holder.append(response)
return response
service.on_job = on_job
async def run():
return await service.run_job(job_id, force=force)
if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed")
console.print("[green]✓[/green] Job executed")
if result_holder:
_print_agent_response(result_holder[0], render_markdown=True)
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
@@ -842,7 +982,9 @@ def status():
p = getattr(config.providers, spec.name, None)
if p is None:
continue
if spec.is_local:
if spec.is_oauth:
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
elif spec.is_local:
# Local deployments show api_base instead of api_key
if p.api_base:
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
@@ -853,5 +995,88 @@ def status():
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
# ============================================================================
# OAuth Login
# ============================================================================
provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")
_LOGIN_HANDLERS: dict[str, callable] = {}
def _register_login(name: str):
def decorator(fn):
_LOGIN_HANDLERS[name] = fn
return fn
return decorator
@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
):
"""Authenticate with an OAuth provider."""
from nanobot.providers.registry import PROVIDERS
key = provider.replace("-", "_")
spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
if not spec:
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
raise typer.Exit(1)
handler = _LOGIN_HANDLERS.get(spec.name)
if not handler:
console.print(f"[red]Login not implemented for {spec.label}[/red]")
raise typer.Exit(1)
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler()
@_register_login("openai_codex")
def _login_openai_codex() -> None:
try:
from oauth_cli_kit import get_token, login_oauth_interactive
token = None
try:
token = get_token()
except Exception:
pass
if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
)
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]")
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
@_register_login("github_copilot")
def _login_github_copilot() -> None:
import asyncio
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
async def _trigger():
from litellm import acompletion
await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1)
try:
asyncio.run(_trigger())
console.print("[green]✓ Authenticated with GitHub Copilot[/green]")
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
app()
+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}")
+24 -43
View File
@@ -2,7 +2,6 @@
import json
from pathlib import Path
from typing import Any
from nanobot.config.schema import Config
@@ -12,12 +11,29 @@ def get_config_path() -> Path:
return Path.home() / ".nanobot" / "config.json"
def _get_oauth_store_dir() -> Path:
"""Get the OAuth store directory."""
return Path.home() / ".nanobot"
def get_data_dir() -> Path:
"""Get the nanobot data directory."""
from nanobot.utils.helpers import get_data_path
return get_data_path()
def _inject_oauth_credentials(config: Config) -> Config:
"""Inject OAuth credentials from store into config if available."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(_get_oauth_store_dir())
creds = store.load("anthropic")
if creds and creds.access_token and not creds.is_expired:
config.providers.anthropic.api_key = creds.access_token
return config
def load_config(config_path: Path | None = None) -> Config:
"""
Load configuration from file or create default.
@@ -32,15 +48,16 @@ def load_config(config_path: Path | None = None) -> Config:
if path.exists():
try:
with open(path) as f:
with open(path, encoding="utf-8") as f:
data = json.load(f)
data = _migrate_config(data)
return Config.model_validate(convert_keys(data))
config = Config.model_validate(data)
return _inject_oauth_credentials(config)
except (json.JSONDecodeError, ValueError) as e:
print(f"Warning: Failed to load config from {path}: {e}")
print("Using default configuration.")
return Config()
return _inject_oauth_credentials(Config())
def save_config(config: Config, config_path: Path | None = None) -> None:
@@ -54,12 +71,10 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
path = config_path or get_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Convert to camelCase format
data = config.model_dump()
data = convert_to_camel(data)
data = config.model_dump(by_alias=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _migrate_config(data: dict) -> dict:
@@ -70,37 +85,3 @@ def _migrate_config(data: dict) -> dict:
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
return data
def convert_keys(data: Any) -> Any:
"""Convert camelCase keys to snake_case for Pydantic."""
if isinstance(data, dict):
return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
if isinstance(data, list):
return [convert_keys(item) for item in data]
return data
def convert_to_camel(data: Any) -> Any:
"""Convert snake_case keys to camelCase."""
if isinstance(data, dict):
return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
if isinstance(data, list):
return [convert_to_camel(item) for item in data]
return data
def camel_to_snake(name: str) -> str:
"""Convert camelCase to snake_case."""
result = []
for i, char in enumerate(name):
if char.isupper() and i > 0:
result.append("_")
result.append(char.lower())
return "".join(result)
def snake_to_camel(name: str) -> str:
"""Convert snake_case to camelCase."""
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
+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
+204 -31
View File
@@ -1,54 +1,89 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings
class WhatsAppConfig(BaseModel):
class Base(BaseModel):
"""Base model that accepts both camelCase and snake_case keys."""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class WhatsAppConfig(Base):
"""WhatsApp channel configuration."""
enabled: bool = False
bridge_url: str = "ws://localhost:3001"
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
class TelegramConfig(BaseModel):
class TelegramConfig(Base):
"""Telegram channel configuration."""
enabled: bool = False
token: str = "" # Bot token from @BotFather
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
reply_to_message: bool = False # If true, bot replies quote the original message
class FeishuConfig(BaseModel):
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
enabled: bool = False
app_id: str = "" # App ID from Feishu Open Platform
app_secret: str = "" # App Secret from Feishu Open Platform
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
verification_token: str = "" # Verification Token for event subscription (optional)
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
class DingTalkConfig(BaseModel):
class DingTalkConfig(Base):
"""DingTalk channel configuration using Stream mode."""
enabled: bool = False
client_id: str = "" # AppKey
client_secret: str = "" # AppSecret
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
class DiscordConfig(BaseModel):
class DiscordConfig(Base):
"""Discord channel configuration."""
enabled: bool = False
token: str = "" # Bot token from Discord Developer Portal
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
class EmailConfig(BaseModel):
class MatrixConfig(Base):
"""Matrix (Element) channel configuration."""
enabled: bool = False
homeserver: str = "https://matrix.org"
access_token: str = ""
user_id: str = "" # @bot:matrix.org
device_id: str = ""
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound).
allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention", "allowlist"] = "open"
group_allow_from: list[str] = Field(default_factory=list)
allow_room_mentions: bool = False
class EmailConfig(Base):
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
enabled: bool = False
consent_granted: bool = False # Explicit owner permission to access mailbox data
@@ -78,18 +113,21 @@ class EmailConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
class MochatMentionConfig(BaseModel):
class MochatMentionConfig(Base):
"""Mochat mention behavior configuration."""
require_in_groups: bool = False
class MochatGroupRule(BaseModel):
class MochatGroupRule(Base):
"""Mochat per-group mention requirement."""
require_mention: bool = False
class MochatConfig(BaseModel):
class MochatConfig(Base):
"""Mochat channel configuration."""
enabled: bool = False
base_url: str = "https://mochat.io"
socket_url: str = ""
@@ -114,36 +152,58 @@ class MochatConfig(BaseModel):
reply_delay_ms: int = 120000
class SlackDMConfig(BaseModel):
class SlackDMConfig(Base):
"""Slack DM policy configuration."""
enabled: bool = True
policy: str = "open" # "open" or "allowlist"
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
class SlackConfig(BaseModel):
class SlackConfig(Base):
"""Slack channel configuration."""
enabled: bool = False
mode: str = "socket" # "socket" supported
webhook_path: str = "/slack/events"
bot_token: str = "" # xoxb-...
app_token: str = "" # xapp-...
user_token_read_only: bool = True
reply_in_thread: bool = True
react_emoji: str = "eyes"
group_policy: str = "mention" # "mention", "open", "allowlist"
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
class QQConfig(BaseModel):
class QQConfig(Base):
"""QQ channel configuration using botpy SDK."""
enabled: bool = False
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
class MatrixConfig(Base):
"""Matrix (Element) channel configuration."""
enabled: bool = False
homeserver: str = "https://matrix.org"
access_token: str = ""
user_id: str = "" # e.g. @bot:matrix.org
device_id: str = ""
e2ee_enabled: bool = True # end-to-end encryption support
sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout
max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit
allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention", "allowlist"] = "open"
group_allow_from: list[str] = Field(default_factory=list)
allow_room_mentions: bool = False
class ChannelsConfig(BaseModel):
class ChannelsConfig(Base):
"""Configuration for chat channels."""
send_progress: bool = True # stream agent's text progress to the channel
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
@@ -153,32 +213,70 @@ class ChannelsConfig(BaseModel):
email: EmailConfig = Field(default_factory=EmailConfig)
slack: SlackConfig = Field(default_factory=SlackConfig)
qq: QQConfig = Field(default_factory=QQConfig)
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
class AgentDefaults(BaseModel):
class AgentDefaults(Base):
"""Default agent configuration."""
workspace: str = "~/.nanobot/workspace"
model: str = "anthropic/claude-opus-4-5"
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
max_tokens: int = 8192
temperature: float = 0.7
max_tool_iterations: int = 20
memory_window: int = 50
temperature: float = 0.1
max_tool_iterations: int = 40
memory_window: int = 100
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
class AgentsConfig(BaseModel):
class AgentsConfig(Base):
"""Agent configuration."""
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
class ProviderConfig(BaseModel):
class OAuthCredentials(BaseModel):
"""OAuth token credentials for subscription-based auth."""
access_token: str = ""
refresh_token: str = ""
expires_at: int = 0 # Unix timestamp
token_type: str = "oauth" # "oauth" or "token" (setup-token)
@property
def is_oauth_token(self) -> bool:
"""Check if this is an OAuth token (vs regular API key)."""
return "sk-ant-oat" in self.access_token
@property
def is_expired(self) -> bool:
"""Check if token has expired."""
import time
if self.expires_at == 0:
return False # No expiry set (setup-token)
return time.time() > self.expires_at
@property
def expires_soon(self) -> bool:
"""Check if token expires within 10 minutes."""
import time
if self.expires_at == 0:
return False
return time.time() > (self.expires_at - 600)
class ProviderConfig(Base):
"""LLM provider configuration."""
api_key: str = ""
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
oauth_credentials: OAuthCredentials | None = None
class ProvidersConfig(BaseModel):
class ProvidersConfig(Base):
"""Configuration for LLM providers."""
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
openai: ProviderConfig = Field(default_factory=ProviderConfig)
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -191,43 +289,96 @@ class ProvidersConfig(BaseModel):
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
class GatewayConfig(BaseModel):
class HeartbeatConfig(Base):
"""Heartbeat service configuration."""
enabled: bool = True
interval_s: int = 30 * 60 # 30 minutes
class GatewayConfig(Base):
"""Gateway/server configuration."""
host: str = "0.0.0.0"
port: int = 18790
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
class WebSearchConfig(BaseModel):
class HooksConfig(Base):
"""Webhook endpoint configuration."""
enabled: bool = False
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
path: str = "/hooks" # URL path for the endpoint
timeout_seconds: int = 120 # Max time to wait for agent response
def resolve_token(self, provided: str) -> str | None:
"""Return token name if provided secret matches, else None."""
for name, secret in self.tokens.items():
if secret == provided:
return name
return None
@property
def has_tokens(self) -> bool:
"""True if at least one token is configured."""
return bool(self.tokens)
class WebSearchConfig(Base):
"""Web search tool configuration."""
api_key: str = "" # Brave Search API key
max_results: int = 5
class WebToolsConfig(BaseModel):
class WebToolsConfig(Base):
"""Web tools configuration."""
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
class ExecToolConfig(BaseModel):
class ExecToolConfig(Base):
"""Shell exec tool configuration."""
timeout: int = 60
path_append: str = ""
class ToolsConfig(BaseModel):
class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
url: str = "" # HTTP: streamable HTTP endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
tool_timeout: int = 30 # Seconds before a tool call is cancelled
class ToolsConfig(Base):
"""Tools configuration."""
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
enable_memory_tool: bool = True # If true, enable Anthropic's native memory tool
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
class Config(BaseSettings):
"""Root configuration for nanobot."""
agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
hooks: HooksConfig = Field(default_factory=HooksConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
@property
@@ -238,16 +389,40 @@ class Config(BaseSettings):
def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
"""Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS
forced = self.agents.defaults.provider
if forced != "auto":
p = getattr(self.providers, forced, None)
return (p, forced) if p else (None, None)
model_lower = (model or self.agents.defaults.model).lower()
model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
def _kw_matches(kw: str) -> bool:
kw = kw.lower()
return kw in model_lower or kw.replace("-", "_") in model_normalized
# Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and model_prefix and normalized_prefix == spec.name:
if spec.is_oauth or p.api_key:
return p, spec.name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
return p, spec.name
if p and any(_kw_matches(kw) for kw in spec.keywords):
if spec.is_oauth or p.api_key:
return p, spec.name
# Fallback: gateways first, then others (follows registry order)
# OAuth providers are NOT valid fallbacks — they require explicit model selection
for spec in PROVIDERS:
if spec.is_oauth:
continue
p = getattr(self.providers, spec.name, None)
if p and p.api_key:
return p, spec.name
@@ -271,6 +446,7 @@ class Config(BaseSettings):
def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways."""
from nanobot.providers.registry import find_by_name
p, name = self._match_provider(model)
if p and p.api_base:
return p.api_base
@@ -283,7 +459,4 @@ class Config(BaseSettings):
return spec.default_api_base
return None
model_config = ConfigDict(
env_prefix="NANOBOT_",
env_nested_delimiter="__"
)
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
+33 -12
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Coroutine
@@ -30,15 +31,34 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
if schedule.kind == "cron" and schedule.expr:
try:
from croniter import croniter
cron = croniter(schedule.expr, time.time())
next_time = cron.get_next()
return int(next_time * 1000)
from zoneinfo import ZoneInfo
# Use caller-provided reference time for deterministic scheduling
base_time = now_ms / 1000
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
base_dt = datetime.fromtimestamp(base_time, tz=tz)
cron = croniter(schedule.expr, base_dt)
next_dt = cron.get_next(datetime)
return int(next_dt.timestamp() * 1000)
except Exception:
return None
return None
def _validate_schedule_for_add(schedule: CronSchedule) -> None:
"""Validate schedule fields that would otherwise create non-runnable jobs."""
if schedule.tz and schedule.kind != "cron":
raise ValueError("tz can only be used with cron schedules")
if schedule.kind == "cron" and schedule.tz:
try:
from zoneinfo import ZoneInfo
ZoneInfo(schedule.tz)
except Exception:
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
class CronService:
"""Service for managing and executing scheduled jobs."""
@@ -60,7 +80,7 @@ class CronService:
if self.store_path.exists():
try:
data = json.loads(self.store_path.read_text())
data = json.loads(self.store_path.read_text(encoding="utf-8"))
jobs = []
for j in data.get("jobs", []):
jobs.append(CronJob(
@@ -93,7 +113,7 @@ class CronService:
))
self._store = CronStore(jobs=jobs)
except Exception as e:
logger.warning(f"Failed to load cron store: {e}")
logger.warning("Failed to load cron store: {}", e)
self._store = CronStore()
else:
self._store = CronStore()
@@ -142,7 +162,7 @@ class CronService:
]
}
self.store_path.write_text(json.dumps(data, indent=2))
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
async def start(self) -> None:
"""Start the cron service."""
@@ -151,7 +171,7 @@ class CronService:
self._recompute_next_runs()
self._save_store()
self._arm_timer()
logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")
logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else []))
def stop(self) -> None:
"""Stop the cron service."""
@@ -216,7 +236,7 @@ class CronService:
async def _execute_job(self, job: CronJob) -> None:
"""Execute a single job."""
start_ms = _now_ms()
logger.info(f"Cron: executing job '{job.name}' ({job.id})")
logger.info("Cron: executing job '{}' ({})", job.name, job.id)
try:
response = None
@@ -225,12 +245,12 @@ class CronService:
job.state.last_status = "ok"
job.state.last_error = None
logger.info(f"Cron: job '{job.name}' completed")
logger.info("Cron: job '{}' completed", job.name)
except Exception as e:
job.state.last_status = "error"
job.state.last_error = str(e)
logger.error(f"Cron: job '{job.name}' failed: {e}")
logger.error("Cron: job '{}' failed: {}", job.name, e)
job.state.last_run_at_ms = start_ms
job.updated_at_ms = _now_ms()
@@ -266,6 +286,7 @@ class CronService:
) -> CronJob:
"""Add a new job."""
store = self._load_store()
_validate_schedule_for_add(schedule)
now = _now_ms()
job = CronJob(
@@ -290,7 +311,7 @@ class CronService:
self._save_store()
self._arm_timer()
logger.info(f"Cron: added job '{name}' ({job.id})")
logger.info("Cron: added job '{}' ({})", name, job.id)
return job
def remove_job(self, job_id: str) -> bool:
@@ -303,7 +324,7 @@ class CronService:
if removed:
self._save_store()
self._arm_timer()
logger.info(f"Cron: removed job {job_id}")
logger.info("Cron: removed job {}", job_id)
return removed
+56 -10
View File
@@ -1,11 +1,16 @@
"""Heartbeat service - periodic agent wake-up to check for tasks."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, Callable, Coroutine
from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
# Default interval: 30 minutes
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
@@ -46,14 +51,20 @@ class HeartbeatService:
def __init__(
self,
workspace: Path,
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
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.on_heartbeat = on_heartbeat
self.interval_s = interval_s
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._task: asyncio.Task | None = None
@@ -101,6 +112,40 @@ class HeartbeatService:
async def _tick(self) -> None:
"""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()
# Skip if HEARTBEAT.md is empty or doesn't exist
@@ -108,17 +153,18 @@ class HeartbeatService:
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
return
logger.info("Heartbeat: checking for tasks...")
logger.info("Heartbeat: user idle, checking for tasks...")
if self.on_heartbeat:
try:
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
# Call with suppress_output metadata
await self.on_heartbeat(
HEARTBEAT_PROMPT,
metadata={"suppress_output": True}
)
# Check if agent said "nothing to do"
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
logger.info("Heartbeat: OK (no action needed)")
else:
logger.info(f"Heartbeat: completed task")
# Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
logger.info("Heartbeat: completed")
except Exception as e:
logger.error(f"Heartbeat execution failed: {e}")
@@ -126,5 +172,5 @@ class HeartbeatService:
async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat."""
if self.on_heartbeat:
return await self.on_heartbeat(HEARTBEAT_PROMPT)
return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
return None
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)
+44 -3
View File
@@ -1,6 +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.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"]
__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
+43 -1
View File
@@ -20,7 +20,7 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
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
def has_tool_calls(self) -> bool:
@@ -40,6 +40,46 @@ class LLMProvider(ABC):
self.api_key = api_key
self.api_base = api_base
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Replace empty text content that causes provider 400 errors.
Empty content can appear when MCP tools return nothing. Most providers
reject empty-string content or empty text blocks in list content.
"""
result: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str) and not content:
clean = dict(msg)
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
result.append(clean)
continue
if isinstance(content, list):
filtered = [
item for item in content
if not (
isinstance(item, dict)
and item.get("type") in ("text", "input_text", "output_text")
and not item.get("text")
)
]
if len(filtered) != len(content):
clean = dict(msg)
if filtered:
clean["content"] = filtered
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
clean["content"] = None
else:
clean["content"] = "(empty)"
result.append(clean)
continue
result.append(msg)
return result
@abstractmethod
async def chat(
self,
@@ -48,6 +88,8 @@ class LLMProvider(ABC):
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 a chat completion request.
+52
View File
@@ -0,0 +1,52 @@
"""Direct OpenAI-compatible provider — bypasses LiteLLM."""
from __future__ import annotations
from typing import Any
import json_repair
from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
class CustomProvider(LLMProvider):
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
super().__init__(api_key, api_base)
self.default_model = default_model
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
kwargs: dict[str, Any] = {
"model": model or self.default_model,
"messages": self._sanitize_empty_content(messages),
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
if tools:
kwargs.update(tools=tools, tool_choice="auto")
try:
return self._parse(await self._client.chat.completions.create(**kwargs))
except Exception as e:
return LLMResponse(content=f"Error: {e}", finish_reason="error")
def _parse(self, response: Any) -> LLMResponse:
choice = response.choices[0]
msg = choice.message
tool_calls = [
ToolCallRequest(id=tc.id, name=tc.function.name,
arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments)
for tc in (msg.tool_calls or [])
]
u = response.usage
return LLMResponse(
content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {},
reasoning_content=getattr(msg, "reasoning_content", None) or None,
)
def get_default_model(self) -> str:
return self.default_model
+87 -8
View File
@@ -1,7 +1,10 @@
"""LiteLLM provider implementation for multi-provider support."""
import json
import json_repair
import os
import secrets
import string
from typing import Any
import litellm
@@ -11,6 +14,16 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway
# Standard OpenAI chat-completion message keys plus reasoning_content for
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
_ALNUM = string.ascii_letters + string.digits
def _short_tool_id() -> str:
"""Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral)."""
return "".join(secrets.choice(_ALNUM) for _ in range(9))
class LiteLLMProvider(LLMProvider):
"""
LLM provider using LiteLLM for multi-provider support.
@@ -54,6 +67,9 @@ class LiteLLMProvider(LLMProvider):
spec = self._gateway or find_by_model(model)
if not spec:
return
if not spec.env_key:
# OAuth/provider-only specs (for example: openai_codex)
return
# Gateway/local overrides existing env; standard provider doesn't
if self._gateway:
@@ -84,11 +100,55 @@ class LiteLLMProvider(LLMProvider):
# Standard mode: auto-prefix for known providers
spec = find_by_model(model)
if spec and spec.litellm_prefix:
model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
if not any(model.startswith(s) for s in spec.skip_prefixes):
model = f"{spec.litellm_prefix}/{model}"
return model
@staticmethod
def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
"""Normalize explicit provider prefixes like `github-copilot/...`."""
if "/" not in model:
return model
prefix, remainder = model.split("/", 1)
if prefix.lower().replace("-", "_") != spec_name:
return model
return f"{canonical_prefix}/{remainder}"
def _supports_cache_control(self, model: str) -> bool:
"""Return True when the provider supports cache_control on content blocks."""
if self._gateway is not None:
return self._gateway.supports_prompt_caching
spec = find_by_model(model)
return spec is not None and spec.supports_prompt_caching
def _apply_cache_control(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
"""Return copies of messages and tools with cache_control injected."""
new_messages = []
for msg in messages:
if msg.get("role") == "system":
content = msg["content"]
if isinstance(content, str):
new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
else:
new_content = list(content)
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
new_messages.append({**msg, "content": new_content})
else:
new_messages.append(msg)
new_tools = tools
if tools:
new_tools = list(tools)
new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
return new_messages, new_tools
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
model_lower = model.lower()
@@ -99,6 +159,18 @@ class LiteLLMProvider(LLMProvider):
kwargs.update(overrides)
return
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
sanitized = []
for msg in messages:
clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
# Strict providers require "content" even when assistant only has tool_calls
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
sanitized.append(clean)
return sanitized
async def chat(
self,
messages: list[dict[str, Any]],
@@ -106,6 +178,8 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
context_management: dict[str, Any] | None = None, # Anthropic-only, ignored here
) -> LLMResponse:
"""
Send a chat completion request via LiteLLM.
@@ -120,11 +194,19 @@ class LiteLLMProvider(LLMProvider):
Returns:
LLMResponse with content and/or tool calls.
"""
model = self._resolve_model(model or self.default_model)
original_model = model or self.default_model
model = self._resolve_model(original_model)
if self._supports_cache_control(original_model):
messages, tools = self._apply_cache_control(messages, tools)
# Clamp max_tokens to at least 1 — negative or zero values cause
# LiteLLM to reject the request with "max_tokens must be at least 1".
max_tokens = max(1, max_tokens)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
"max_tokens": max_tokens,
"temperature": temperature,
}
@@ -169,13 +251,10 @@ class LiteLLMProvider(LLMProvider):
# Parse arguments from JSON string if needed
args = tc.function.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {"raw": args}
args = json_repair.loads(args)
tool_calls.append(ToolCallRequest(
id=tc.id,
id=_short_tool_id(),
name=tc.function.name,
arguments=args,
))
@@ -188,7 +267,7 @@ class LiteLLMProvider(LLMProvider):
"total_tokens": response.usage.total_tokens,
}
reasoning_content = getattr(message, "reasoning_content", None)
reasoning_content = getattr(message, "reasoning_content", None) or None
return LLMResponse(
content=message.content,
+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
+312
View File
@@ -0,0 +1,312 @@
"""OpenAI Codex Responses Provider."""
from __future__ import annotations
import asyncio
import hashlib
import json
from typing import Any, AsyncGenerator
import httpx
from loguru import logger
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot"
class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API."""
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
super().__init__(api_key=None, api_base=None)
self.default_model = default_model
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
model = model or self.default_model
system_prompt, input_items = _convert_messages(messages)
token = await asyncio.to_thread(get_codex_token)
headers = _build_headers(token.account_id, token.access)
body: dict[str, Any] = {
"model": _strip_model_prefix(model),
"store": False,
"stream": True,
"instructions": system_prompt,
"input": input_items,
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": _prompt_cache_key(messages),
"tool_choice": "auto",
"parallel_tool_calls": True,
}
if tools:
body["tools"] = _convert_tools(tools)
url = DEFAULT_CODEX_URL
try:
try:
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
)
except Exception as e:
return LLMResponse(
content=f"Error calling Codex: {str(e)}",
finish_reason="error",
)
def get_default_model(self) -> str:
return self.default_model
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
return model.split("/", 1)[1]
return model
def _build_headers(account_id: str, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": DEFAULT_ORIGINATOR,
"User-Agent": "nanobot (python)",
"accept": "text/event-stream",
"content-type": "application/json",
}
async def _request_codex(
url: str,
headers: dict[str, str],
body: dict[str, Any],
verify: bool,
) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200:
text = await response.aread()
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
return await _consume_sse(response)
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI function-calling schema to Codex flat format."""
converted: list[dict[str, Any]] = []
for tool in tools:
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
name = fn.get("name")
if not name:
continue
params = fn.get("parameters") or {}
converted.append({
"type": "function",
"name": name,
"description": fn.get("description") or "",
"parameters": params if isinstance(params, dict) else {},
})
return converted
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
input_items: list[dict[str, Any]] = []
for idx, msg in enumerate(messages):
role = msg.get("role")
content = msg.get("content")
if role == "system":
system_prompt = content if isinstance(content, str) else ""
continue
if role == "user":
input_items.append(_convert_user_message(content))
continue
if role == "assistant":
# Handle text first.
if isinstance(content, str) and content:
input_items.append(
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed",
"id": f"msg_{idx}",
}
)
# Then handle tool calls.
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
call_id = call_id or f"call_{idx}"
item_id = item_id or f"fc_{idx}"
input_items.append(
{
"type": "function_call",
"id": item_id,
"call_id": call_id,
"name": fn.get("name"),
"arguments": fn.get("arguments") or "{}",
}
)
continue
if role == "tool":
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
input_items.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": output_text,
}
)
continue
return system_prompt, input_items
def _convert_user_message(content: Any) -> dict[str, Any]:
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
if isinstance(content, list):
converted: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
converted.append({"type": "input_text", "text": item.get("text", "")})
elif item.get("type") == "image_url":
url = (item.get("image_url") or {}).get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
if converted:
return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
if isinstance(tool_call_id, str) and tool_call_id:
if "|" in tool_call_id:
call_id, item_id = tool_call_id.split("|", 1)
return call_id, item_id or None
return tool_call_id, None
return "call_0", None
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
buffer: list[str] = []
async for line in response.aiter_lines():
if line == "":
if buffer:
data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
buffer = []
if not data_lines:
continue
data = "\n".join(data_lines).strip()
if not data or data == "[DONE]":
continue
try:
yield json.loads(data)
except Exception:
continue
continue
buffer.append(line)
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
finish_reason = "stop"
async for event in _iter_sse(response):
event_type = event.get("type")
if event_type == "response.output_item.added":
item = event.get("item") or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
continue
tool_call_buffers[call_id] = {
"id": item.get("id") or "fc_0",
"name": item.get("name"),
"arguments": item.get("arguments") or "",
}
elif event_type == "response.output_text.delta":
content += event.get("delta") or ""
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
try:
args = json.loads(args_raw)
except Exception:
args = {"raw": args_raw}
tool_calls.append(
ToolCallRequest(
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
name=buf.get("name") or item.get("name"),
arguments=args,
)
)
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
finish_reason = _map_finish_reason(status)
elif event_type in {"error", "response.failed"}:
raise RuntimeError("Codex response failed")
return content, tool_calls, finish_reason
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
def _map_finish_reason(status: str | None) -> str:
return _FINISH_REASON_MAP.get(status or "completed", "stop")
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
return f"HTTP {status_code}: {raw}"
+128 -4
View File
@@ -51,6 +51,15 @@ class ProviderSpec:
# per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
is_oauth: bool = False # if True, uses OAuth flow instead of API key
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
is_direct: bool = False
# Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
supports_prompt_caching: bool = False
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -62,6 +71,16 @@ class ProviderSpec:
PROVIDERS: tuple[ProviderSpec, ...] = (
# === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ======
ProviderSpec(
name="custom",
keywords=(),
env_key="",
display_name="Custom",
litellm_prefix="",
is_direct=True,
),
# === Gateways (detected by api_key / api_base, not model name) =========
# Gateways can route any model, so they win in fallback.
@@ -81,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://openrouter.ai/api/v1",
strip_model_prefix=False,
model_overrides=(),
supports_prompt_caching=True,
),
# AiHubMix: global gateway, OpenAI-compatible interface.
@@ -103,6 +123,42 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
# SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix
ProviderSpec(
name="siliconflow",
keywords=("siliconflow",),
env_key="OPENAI_API_KEY",
display_name="SiliconFlow",
litellm_prefix="openai",
skip_prefixes=(),
env_extras=(),
is_gateway=True,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="siliconflow",
default_api_base="https://api.siliconflow.cn/v1",
strip_model_prefix=False,
model_overrides=(),
),
# VolcEngine (火山引擎): OpenAI-compatible gateway
ProviderSpec(
name="volcengine",
keywords=("volcengine", "volces", "ark"),
env_key="OPENAI_API_KEY",
display_name="VolcEngine",
litellm_prefix="volcengine",
skip_prefixes=(),
env_extras=(),
is_gateway=True,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="volces",
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
strip_model_prefix=False,
model_overrides=(),
),
# === Standard providers (matched by model-name keywords) ===============
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
@@ -121,6 +177,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="",
strip_model_prefix=False,
model_overrides=(),
supports_prompt_caching=True,
),
# OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
@@ -141,6 +198,44 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
# OpenAI Codex: uses OAuth, not API key.
ProviderSpec(
name="openai_codex",
keywords=("openai-codex", "codex"),
env_key="", # OAuth-based, no API key
display_name="OpenAI Codex",
litellm_prefix="", # Not routed through LiteLLM
skip_prefixes=(),
env_extras=(),
is_gateway=False,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="codex",
default_api_base="https://chatgpt.com/backend-api",
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
),
# Github Copilot: uses OAuth, not API key.
ProviderSpec(
name="github_copilot",
keywords=("github_copilot", "copilot"),
env_key="", # OAuth-based, no API key
display_name="Github Copilot",
litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model
skip_prefixes=("github_copilot/",),
env_extras=(),
is_gateway=False,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="",
default_api_base="",
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
),
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
ProviderSpec(
name="deepseek",
@@ -312,10 +407,18 @@ def find_by_model(model: str) -> ProviderSpec | None:
"""Match a standard provider by model-name keyword (case-insensitive).
Skips gateways/local — those are matched by api_key/api_base instead."""
model_lower = model.lower()
for spec in PROVIDERS:
if spec.is_gateway or spec.is_local:
continue
if any(kw in model_lower for kw in spec.keywords):
model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
# Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex.
for spec in std_specs:
if model_prefix and normalized_prefix == spec.name:
return spec
for spec in std_specs:
if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
return spec
return None
@@ -357,3 +460,24 @@ def find_by_name(name: str) -> ProviderSpec | None:
if spec.name == name:
return spec
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
+2 -2
View File
@@ -35,7 +35,7 @@ class GroqTranscriptionProvider:
path = Path(file_path)
if not path.exists():
logger.error(f"Audio file not found: {file_path}")
logger.error("Audio file not found: {}", file_path)
return ""
try:
@@ -61,5 +61,5 @@ class GroqTranscriptionProvider:
return data.get("text", "")
except Exception as e:
logger.error(f"Groq transcription error: {e}")
logger.error("Groq transcription error: {}", e)
return ""
+23 -11
View File
@@ -36,21 +36,33 @@ class Session:
self.messages.append(msg)
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
"""
Get message history for LLM context.
def add_raw_message(self, msg: dict[str, Any]) -> None:
"""Add a pre-formed message dict to the session, preserving all fields."""
stored = dict(msg)
if "timestamp" not in stored:
stored["timestamp"] = datetime.now().isoformat()
self.messages.append(stored)
self.updated_at = datetime.now()
Args:
max_messages: Maximum messages to return.
# Fields that are valid in the Anthropic/OpenAI messages API.
# Everything else (timestamp, tools_used, etc.) is internal metadata.
_API_FIELDS = {"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}
def get_history(self) -> list[dict[str, Any]]:
"""
Get full message history for LLM context.
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.
List of messages in LLM format (API-relevant fields only).
"""
# Get recent messages
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
# Convert to LLM format (just role and content)
return [{"role": m["role"], "content": m["content"]} for m in recent]
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:
"""Clear all messages in the session."""
+1
View File
@@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
| `weather` | Get weather info using wttr.in and Open-Meteo |
| `summarize` | Summarize URLs, files, and YouTube videos |
| `tmux` | Remote-control tmux sessions |
| `clawhub` | Search and install skills from ClawHub registry |
| `skill-creator` | Create new skills |
+53
View File
@@ -0,0 +1,53 @@
---
name: clawhub
description: Search and install agent skills from ClawHub, the public skill registry.
homepage: https://clawhub.ai
metadata: {"nanobot":{"emoji":"🦞"}}
---
# ClawHub
Public skill registry for AI agents. Search by natural language (vector search).
## When to use
Use this skill when the user asks any of:
- "find a skill for …"
- "search for skills"
- "install a skill"
- "what skills are available?"
- "update my skills"
## Search
```bash
npx --yes clawhub@latest search "web scraping" --limit 5
```
## Install
```bash
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace
```
Replace `<slug>` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
## Update
```bash
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace
```
## List installed
```bash
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
```
## Notes
- Requires Node.js (`npx` comes with it).
- No API key needed for search and install.
- Login (`npx --yes clawhub@latest login`) is only required for publishing.
- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
- After install, remind the user to start a new session to load the skill.
+10
View File
@@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
```
Timezone-aware cron:
```
cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
```
List/remove:
```
cron(action="list")
@@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
| at a specific time | at: ISO datetime string (compute from current time) |
## Timezone
Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
+1 -1
View File
@@ -9,7 +9,7 @@ always: true
## Structure
- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context.
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep.
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM].
## Search Past Events
+23
View File
@@ -0,0 +1,23 @@
# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
## Scheduled Reminders
When user asks for a reminder at a specific time, use `exec` to run:
```
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
```
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
## Heartbeat Tasks
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
- **Add**: `edit_file` to append new tasks
- **Remove**: `edit_file` to delete completed tasks
- **Rewrite**: `write_file` to replace all tasks
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
+15
View File
@@ -0,0 +1,15 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling.
This file documents non-obvious constraints and usage patterns.
## exec — Safety Limits
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
- Output is truncated at 10,000 characters
- `restrictToWorkspace` config can limit file access to the workspace
## cron — Scheduled Reminders
- Please refer to cron skill for usage.
View File
+38 -51
View File
@@ -1,80 +1,67 @@
"""Utility functions for nanobot."""
import re
from pathlib import Path
from datetime import datetime
def ensure_dir(path: Path) -> Path:
"""Ensure a directory exists, creating it if necessary."""
"""Ensure directory exists, return it."""
path.mkdir(parents=True, exist_ok=True)
return path
def get_data_path() -> Path:
"""Get the nanobot data directory (~/.nanobot)."""
"""~/.nanobot data directory."""
return ensure_dir(Path.home() / ".nanobot")
def get_workspace_path(workspace: str | None = None) -> Path:
"""
Get the workspace path.
Args:
workspace: Optional workspace path. Defaults to ~/.nanobot/workspace.
Returns:
Expanded and ensured workspace path.
"""
if workspace:
path = Path(workspace).expanduser()
else:
path = Path.home() / ".nanobot" / "workspace"
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
return ensure_dir(path)
def get_sessions_path() -> Path:
"""Get the sessions storage directory."""
return ensure_dir(get_data_path() / "sessions")
def get_skills_path(workspace: Path | None = None) -> Path:
"""Get the skills directory within the workspace."""
ws = workspace or get_workspace_path()
return ensure_dir(ws / "skills")
def timestamp() -> str:
"""Get current timestamp in ISO format."""
"""Current ISO timestamp."""
return datetime.now().isoformat()
def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str:
"""Truncate a string to max length, adding suffix if truncated."""
if len(s) <= max_len:
return s
return s[: max_len - len(suffix)] + suffix
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
def safe_filename(name: str) -> str:
"""Convert a string to a safe filename."""
# Replace unsafe characters
unsafe = '<>:"/\\|?*'
for char in unsafe:
name = name.replace(char, "_")
return name.strip()
"""Replace unsafe path characters with underscores."""
return _UNSAFE_CHARS.sub("_", name).strip()
def parse_session_key(key: str) -> tuple[str, str]:
"""
Parse a session key into channel and chat_id.
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
"""Sync bundled templates to workspace. Only creates missing files."""
from importlib.resources import files as pkg_files
try:
tpl = pkg_files("nanobot") / "templates"
except Exception:
return []
if not tpl.is_dir():
return []
Args:
key: Session key in format "channel:chat_id"
added: list[str] = []
Returns:
Tuple of (channel, chat_id)
"""
parts = key.split(":", 1)
if len(parts) != 2:
raise ValueError(f"Invalid session key: {key}")
return parts[0], parts[1]
def _write(src, dest: Path):
if dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
added.append(str(dest.relative_to(workspace)))
for item in tpl.iterdir():
if item.name.endswith(".md"):
_write(item, workspace / item.name)
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
_write(None, workspace / "memory" / "HISTORY.md")
(workspace / "skills").mkdir(exist_ok=True)
if added and not silent:
from rich.console import Console
for name in added:
Console().print(f" [dim]Created {name}[/dim]")
return added
+1
View File
@@ -38,6 +38,7 @@ dependencies = [
"qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0",
"vncdotool>=1.0.0",
]
[project.optional-dependencies]
+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()
+2 -1
View File
@@ -12,7 +12,8 @@ def mock_prompt_session():
"""Mock the global prompt session."""
mock_session = MagicMock()
mock_session.prompt_async = AsyncMock()
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session):
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
patch("nanobot.cli.commands.patch_stdout"):
yield mock_session
+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
+130
View File
@@ -0,0 +1,130 @@
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import app
from nanobot.config.schema import Config
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_model
runner = CliRunner()
@pytest.fixture
def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
if base_dir.exists():
shutil.rmtree(base_dir)
base_dir.mkdir()
config_file = base_dir / "config.json"
workspace_dir = base_dir / "workspace"
mock_cp.return_value = config_file
mock_ws.return_value = workspace_dir
mock_sc.side_effect = lambda config: config_file.write_text("{}")
yield config_file, workspace_dir
if base_dir.exists():
shutil.rmtree(base_dir)
def test_onboard_fresh_install(mock_paths):
"""No existing config — should create from scratch."""
config_file, workspace_dir = mock_paths
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "Created config" in result.stdout
assert "Created workspace" in result.stdout
assert "nanobot is ready" in result.stdout
assert config_file.exists()
assert (workspace_dir / "AGENTS.md").exists()
assert (workspace_dir / "memory" / "MEMORY.md").exists()
def test_onboard_existing_config_refresh(mock_paths):
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
config_file, workspace_dir = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
assert "existing values preserved" in result.stdout
assert workspace_dir.exists()
assert (workspace_dir / "AGENTS.md").exists()
def test_onboard_existing_config_overwrite(mock_paths):
"""Config exists, user confirms overwrite — should reset to defaults."""
config_file, workspace_dir = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="y\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
assert "Config reset to defaults" in result.stdout
assert workspace_dir.exists()
def test_onboard_existing_workspace_safe_create(mock_paths):
"""Workspace exists — should not recreate, but still add missing templates."""
config_file, workspace_dir = mock_paths
workspace_dir.mkdir(parents=True)
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
assert "Created workspace" not in result.stdout
assert "Created AGENTS.md" in result.stdout
assert (workspace_dir / "AGENTS.md").exists()
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
assert config.get_provider_name() == "github_copilot"
def test_config_matches_openai_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
assert config.get_provider_name() == "openai_codex"
def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
spec = find_by_model("github-copilot/gpt-5.3-codex")
assert spec is not None
assert spec.name == "github_copilot"
def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex")
resolved = provider._resolve_model("github-copilot/gpt-5.3-codex")
assert resolved == "github_copilot/gpt-5.3-codex"
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
+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"
+828
View File
@@ -0,0 +1,828 @@
"""Test session management with cache-friendly message handling."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
# Test constants
MEMORY_WINDOW = 50
KEEP_COUNT = MEMORY_WINDOW // 2 # 25
def create_session_with_messages(key: str, count: int, role: str = "user") -> Session:
"""Create a session and add the specified number of messages.
Args:
key: Session identifier
count: Number of messages to add
role: Message role (default: "user")
Returns:
Session with the specified messages
"""
session = Session(key=key)
for i in range(count):
session.add_message(role, f"msg{i}")
return session
def assert_messages_content(messages: list, start_index: int, end_index: int) -> None:
"""Assert that messages contain expected content from start to end index.
Args:
messages: List of message dictionaries
start_index: Expected first message index
end_index: Expected last message index
"""
assert len(messages) > 0
assert messages[0]["content"] == f"msg{start_index}"
assert messages[-1]["content"] == f"msg{end_index}"
def get_old_messages(session: Session, last_consolidated: int, keep_count: int) -> list:
"""Extract messages that would be consolidated using the standard slice logic.
Args:
session: The session containing messages
last_consolidated: Index of last consolidated message
keep_count: Number of recent messages to keep
Returns:
List of messages that would be consolidated
"""
return session.messages[last_consolidated:-keep_count]
class TestSessionLastConsolidated:
"""Test last_consolidated tracking to avoid duplicate processing."""
def test_initial_last_consolidated_zero(self) -> None:
"""Test that new session starts with last_consolidated=0."""
session = Session(key="test:initial")
assert session.last_consolidated == 0
def test_last_consolidated_persistence(self, tmp_path) -> None:
"""Test that last_consolidated persists across save/load."""
manager = SessionManager(Path(tmp_path))
session1 = create_session_with_messages("test:persist", 20)
session1.last_consolidated = 15
manager.save(session1)
session2 = manager.get_or_create("test:persist")
assert session2.last_consolidated == 15
assert len(session2.messages) == 20
def test_clear_resets_last_consolidated(self) -> None:
"""Test that clear() resets last_consolidated to 0."""
session = create_session_with_messages("test:clear", 10)
session.last_consolidated = 5
session.clear()
assert len(session.messages) == 0
assert session.last_consolidated == 0
class TestSessionImmutableHistory:
"""Test Session message immutability for cache efficiency."""
def test_initial_state(self) -> None:
"""Test that new session has empty messages list."""
session = Session(key="test:initial")
assert len(session.messages) == 0
def test_add_messages_appends_only(self) -> None:
"""Test that adding messages only appends, never modifies."""
session = Session(key="test:preserve")
session.add_message("user", "msg1")
session.add_message("assistant", "resp1")
session.add_message("user", "msg2")
assert len(session.messages) == 3
assert session.messages[0]["content"] == "msg1"
def test_get_history_returns_most_recent(self) -> None:
"""Test get_history returns the most recent messages."""
session = Session(key="test:history")
for i in range(10):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
history = session.get_history(max_messages=6)
assert len(history) == 6
assert history[0]["content"] == "msg7"
assert history[-1]["content"] == "resp9"
def test_get_history_with_all_messages(self) -> None:
"""Test get_history with max_messages larger than actual."""
session = create_session_with_messages("test:all", 5)
history = session.get_history(max_messages=100)
assert len(history) == 5
assert history[0]["content"] == "msg0"
def test_get_history_stable_for_same_session(self) -> None:
"""Test that get_history returns same content for same max_messages."""
session = create_session_with_messages("test:stable", 20)
history1 = session.get_history(max_messages=10)
history2 = session.get_history(max_messages=10)
assert history1 == history2
def test_messages_list_never_modified(self) -> None:
"""Test that messages list is never modified after creation."""
session = create_session_with_messages("test:immutable", 5)
original_len = len(session.messages)
session.get_history(max_messages=2)
assert len(session.messages) == original_len
for _ in range(10):
session.get_history(max_messages=3)
assert len(session.messages) == original_len
class TestSessionPersistence:
"""Test Session persistence and reload."""
@pytest.fixture
def temp_manager(self, tmp_path):
return SessionManager(Path(tmp_path))
def test_persistence_roundtrip(self, temp_manager):
"""Test that messages persist across save/load."""
session1 = create_session_with_messages("test:persistence", 20)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:persistence")
assert len(session2.messages) == 20
assert session2.messages[0]["content"] == "msg0"
assert session2.messages[-1]["content"] == "msg19"
def test_get_history_after_reload(self, temp_manager):
"""Test that get_history works correctly after reload."""
session1 = create_session_with_messages("test:reload", 30)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:reload")
history = session2.get_history(max_messages=10)
assert len(history) == 10
assert history[0]["content"] == "msg20"
assert history[-1]["content"] == "msg29"
def test_clear_resets_session(self, temp_manager):
"""Test that clear() properly resets session."""
session = create_session_with_messages("test:clear", 10)
assert len(session.messages) == 10
session.clear()
assert len(session.messages) == 0
class TestConsolidationTriggerConditions:
"""Test consolidation trigger conditions and logic."""
def test_consolidation_needed_when_messages_exceed_window(self):
"""Test consolidation logic: should trigger when messages > memory_window."""
session = create_session_with_messages("test:trigger", 60)
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert total_messages > MEMORY_WINDOW
assert messages_to_process > 0
expected_consolidate_count = total_messages - KEEP_COUNT
assert expected_consolidate_count == 35
def test_consolidation_skipped_when_within_keep_count(self):
"""Test consolidation skipped when total messages <= keep_count."""
session = create_session_with_messages("test:skip", 20)
total_messages = len(session.messages)
assert total_messages <= KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_consolidation_skipped_when_no_new_messages(self):
"""Test consolidation skipped when messages_to_process <= 0."""
session = create_session_with_messages("test:already_consolidated", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add a few more messages
for i in range(40, 42):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process > 0
# Simulate last_consolidated catching up
session.last_consolidated = total_messages - KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
class TestLastConsolidatedEdgeCases:
"""Test last_consolidated edge cases and data corruption scenarios."""
def test_last_consolidated_exceeds_message_count(self):
"""Test behavior when last_consolidated > len(messages) (data corruption)."""
session = create_session_with_messages("test:corruption", 10)
session.last_consolidated = 20
total_messages = len(session.messages)
messages_to_process = total_messages - session.last_consolidated
assert messages_to_process <= 0
old_messages = get_old_messages(session, session.last_consolidated, 5)
assert len(old_messages) == 0
def test_last_consolidated_negative_value(self):
"""Test behavior with negative last_consolidated (invalid state)."""
session = create_session_with_messages("test:negative", 10)
session.last_consolidated = -5
keep_count = 3
old_messages = get_old_messages(session, session.last_consolidated, keep_count)
# messages[-5:-3] with 10 messages gives indices 5,6
assert len(old_messages) == 2
assert old_messages[0]["content"] == "msg5"
assert old_messages[-1]["content"] == "msg6"
def test_messages_added_after_consolidation(self):
"""Test correct behavior when new messages arrive after consolidation."""
session = create_session_with_messages("test:new_messages", 40)
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
# Add new messages after consolidation
for i in range(40, 50):
session.add_message("user", f"msg{i}")
total_messages = len(session.messages)
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_consolidate_count = total_messages - KEEP_COUNT - session.last_consolidated
assert len(old_messages) == expected_consolidate_count
assert_messages_content(old_messages, 15, 24)
def test_slice_behavior_when_indices_overlap(self):
"""Test slice behavior when last_consolidated >= total - keep_count."""
session = create_session_with_messages("test:overlap", 30)
session.last_consolidated = 12
old_messages = get_old_messages(session, session.last_consolidated, 20)
assert len(old_messages) == 0
class TestArchiveAllMode:
"""Test archive_all mode (used by /new command)."""
def test_archive_all_consolidates_everything(self):
"""Test archive_all=True consolidates all messages."""
session = create_session_with_messages("test:archive_all", 50)
archive_all = True
if archive_all:
old_messages = session.messages
assert len(old_messages) == 50
assert session.last_consolidated == 0
def test_archive_all_resets_last_consolidated(self):
"""Test that archive_all mode resets last_consolidated to 0."""
session = create_session_with_messages("test:reset", 40)
session.last_consolidated = 15
archive_all = True
if archive_all:
session.last_consolidated = 0
assert session.last_consolidated == 0
assert len(session.messages) == 40
def test_archive_all_vs_normal_consolidation(self):
"""Test difference between archive_all and normal consolidation."""
# Normal consolidation
session1 = create_session_with_messages("test:normal", 60)
session1.last_consolidated = len(session1.messages) - KEEP_COUNT
# archive_all mode
session2 = create_session_with_messages("test:all", 60)
session2.last_consolidated = 0
assert session1.last_consolidated == 35
assert len(session1.messages) == 60
assert session2.last_consolidated == 0
assert len(session2.messages) == 60
class TestCacheImmutability:
"""Test that consolidation doesn't modify session.messages (cache safety)."""
def test_consolidation_does_not_modify_messages_list(self):
"""Test that consolidation leaves messages list unchanged."""
session = create_session_with_messages("test:immutable", 50)
original_messages = session.messages.copy()
original_len = len(session.messages)
session.last_consolidated = original_len - KEEP_COUNT
assert len(session.messages) == original_len
assert session.messages == original_messages
def test_get_history_does_not_modify_messages(self):
"""Test that get_history doesn't modify messages list."""
session = create_session_with_messages("test:history_immutable", 40)
original_messages = [m.copy() for m in session.messages]
for _ in range(5):
history = session.get_history(max_messages=10)
assert len(history) == 10
assert len(session.messages) == 40
for i, msg in enumerate(session.messages):
assert msg["content"] == original_messages[i]["content"]
def test_consolidation_only_updates_last_consolidated(self):
"""Test that consolidation only updates last_consolidated field."""
session = create_session_with_messages("test:field_only", 60)
original_messages = session.messages.copy()
original_key = session.key
original_metadata = session.metadata.copy()
session.last_consolidated = len(session.messages) - KEEP_COUNT
assert session.messages == original_messages
assert session.key == original_key
assert session.metadata == original_metadata
assert session.last_consolidated == 35
class TestSliceLogic:
"""Test the slice logic: messages[last_consolidated:-keep_count]."""
def test_slice_extracts_correct_range(self):
"""Test that slice extracts the correct message range."""
session = create_session_with_messages("test:slice", 60)
old_messages = get_old_messages(session, 0, KEEP_COUNT)
assert len(old_messages) == 35
assert_messages_content(old_messages, 0, 34)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 35, 59)
def test_slice_with_partial_consolidation(self):
"""Test slice when some messages already consolidated."""
session = create_session_with_messages("test:partial", 70)
last_consolidated = 30
old_messages = get_old_messages(session, last_consolidated, KEEP_COUNT)
assert len(old_messages) == 15
assert_messages_content(old_messages, 30, 44)
def test_slice_with_various_keep_counts(self):
"""Test slice behavior with different keep_count values."""
session = create_session_with_messages("test:keep_counts", 50)
test_cases = [(10, 40), (20, 30), (30, 20), (40, 10)]
for keep_count, expected_count in test_cases:
old_messages = session.messages[0:-keep_count]
assert len(old_messages) == expected_count
def test_slice_when_keep_count_exceeds_messages(self):
"""Test slice when keep_count > len(messages)."""
session = create_session_with_messages("test:exceed", 10)
old_messages = session.messages[0:-20]
assert len(old_messages) == 0
class TestEmptyAndBoundarySessions:
"""Test empty sessions and boundary conditions."""
def test_empty_session_consolidation(self):
"""Test consolidation behavior with empty session."""
session = Session(key="test:empty")
assert len(session.messages) == 0
assert session.last_consolidated == 0
messages_to_process = len(session.messages) - session.last_consolidated
assert messages_to_process == 0
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_single_message_session(self):
"""Test consolidation with single message."""
session = Session(key="test:single")
session.add_message("user", "only message")
assert len(session.messages) == 1
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_exactly_keep_count_messages(self):
"""Test session with exactly keep_count messages."""
session = create_session_with_messages("test:exact", KEEP_COUNT)
assert len(session.messages) == KEEP_COUNT
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 0
def test_just_over_keep_count(self):
"""Test session with one message over keep_count."""
session = create_session_with_messages("test:over", KEEP_COUNT + 1)
assert len(session.messages) == 26
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 1
assert old_messages[0]["content"] == "msg0"
def test_very_large_session(self):
"""Test consolidation with very large message count."""
session = create_session_with_messages("test:large", 1000)
assert len(session.messages) == 1000
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
assert len(old_messages) == 975
assert_messages_content(old_messages, 0, 974)
remaining = session.messages[-KEEP_COUNT:]
assert len(remaining) == 25
assert_messages_content(remaining, 975, 999)
def test_session_with_gaps_in_consolidation(self):
"""Test session with potential gaps in consolidation history."""
session = create_session_with_messages("test:gaps", 50)
session.last_consolidated = 10
# Add more messages
for i in range(50, 60):
session.add_message("user", f"msg{i}")
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
expected_count = 60 - KEEP_COUNT - 10
assert len(old_messages) == expected_count
assert_messages_content(old_messages, 10, 34)
class TestConsolidationDeduplicationGuard:
"""Test that consolidation tasks are deduplicated and serialized."""
@pytest.mark.asyncio
async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None:
"""Concurrent messages above memory_window spawn only one consolidation task."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
consolidation_calls = 0
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
nonlocal consolidation_calls
consolidation_calls += 1
await asyncio.sleep(0.05)
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await loop._process_message(msg)
await asyncio.sleep(0.1)
assert consolidation_calls == 1, (
f"Expected exactly 1 consolidation, got {consolidation_calls}"
)
@pytest.mark.asyncio
async def test_new_command_guard_prevents_concurrent_consolidation(
self, tmp_path: Path
) -> None:
"""/new command does not run consolidation concurrently with in-flight consolidation."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
consolidation_calls = 0
active = 0
max_active = 0
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
nonlocal consolidation_calls, active, max_active
consolidation_calls += 1
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.05)
active -= 1
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
await loop._process_message(new_msg)
await asyncio.sleep(0.1)
assert consolidation_calls == 2, (
f"Expected normal + /new consolidations, got {consolidation_calls}"
)
assert max_active == 1, (
f"Expected serialized consolidation, observed concurrency={max_active}"
)
@pytest.mark.asyncio
async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None:
"""create_task results are tracked in _consolidation_tasks while in flight."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
async def _slow_consolidate(_session, archive_all: bool = False) -> None:
started.set()
await asyncio.sleep(0.1)
loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight"
await asyncio.sleep(0.15)
assert len(loop._consolidation_tasks) == 0, (
"Task reference must be removed after completion"
)
@pytest.mark.asyncio
async def test_new_waits_for_inflight_consolidation_and_preserves_messages(
self, tmp_path: Path
) -> None:
"""/new waits for in-flight consolidation and archives before clear."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
release = asyncio.Event()
archived_count = 0
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
nonlocal archived_count
if archive_all:
archived_count = len(sess.messages)
return True
started.set()
await release.wait()
return True
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
pending_new = asyncio.create_task(loop._process_message(new_msg))
await asyncio.sleep(0.02)
assert not pending_new.done(), "/new should wait while consolidation is in-flight"
release.set()
response = await pending_new
assert response is not None
assert "new session started" in response.content.lower()
assert archived_count > 0, "Expected /new archival to process a non-empty snapshot"
session_after = loop.sessions.get_or_create("cli:test")
assert session_after.messages == [], "Session should be cleared after successful archival"
@pytest.mark.asyncio
async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
"""/new must keep session data if archive step reports failure."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
before_count = len(session.messages)
async def _failing_consolidate(sess, archive_all: bool = False) -> bool:
if archive_all:
return False
return True
loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
assert response is not None
assert "failed" in response.content.lower()
session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == before_count, (
"Session must remain intact when /new archival fails"
)
@pytest.mark.asyncio
async def test_new_archives_only_unconsolidated_messages_after_inflight_task(
self, tmp_path: Path
) -> None:
"""/new should archive only messages not yet consolidated by prior task."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
started = asyncio.Event()
release = asyncio.Event()
archived_count = -1
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
nonlocal archived_count
if archive_all:
archived_count = len(sess.messages)
return True
started.set()
await release.wait()
sess.last_consolidated = len(sess.messages) - 3
return True
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
await loop._process_message(msg)
await started.wait()
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
pending_new = asyncio.create_task(loop._process_message(new_msg))
await asyncio.sleep(0.02)
assert not pending_new.done()
release.set()
response = await pending_new
assert response is not None
assert "new session started" in response.content.lower()
assert archived_count == 3, (
f"Expected only unconsolidated tail to archive, got {archived_count}"
)
@pytest.mark.asyncio
async def test_new_cleans_up_consolidation_lock_for_invalidated_session(
self, tmp_path: Path
) -> None:
"""/new should remove lock entry for fully invalidated session key."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
)
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
# Ensure lock exists before /new.
loop._consolidation_locks.setdefault(session.key, asyncio.Lock())
assert session.key in loop._consolidation_locks
async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
return True
loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
assert response is not None
assert "new session started" in response.content.lower()
assert session.key not in loop._consolidation_locks
+66
View File
@@ -0,0 +1,66 @@
"""Tests for cache-friendly prompt construction."""
from __future__ import annotations
from datetime import datetime as real_datetime
from pathlib import Path
import datetime as datetime_module
from nanobot.agent.context import ContextBuilder
class _FakeDatetime(real_datetime):
current = real_datetime(2026, 2, 24, 13, 59)
@classmethod
def now(cls, tz=None): # type: ignore[override]
return cls.current
def _make_workspace(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace"
workspace.mkdir(parents=True)
return workspace
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
"""System prompt should not change just because wall clock minute changes."""
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
_FakeDatetime.current = real_datetime(2026, 2, 24, 13, 59)
prompt1 = builder.build_system_prompt()
_FakeDatetime.current = real_datetime(2026, 2, 24, 14, 0)
prompt2 = builder.build_system_prompt()
assert prompt1 == prompt2
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
"""Runtime metadata should be a separate user message before the actual user message."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
messages = builder.build_messages(
history=[],
current_message="Return exactly: OK",
channel="cli",
chat_id="direct",
)
assert messages[0]["role"] == "system"
assert "## Current Session" not in messages[0]["content"]
assert messages[-2]["role"] == "user"
runtime_content = messages[-2]["content"]
assert isinstance(runtime_content, str)
assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content
assert "Current Time:" in runtime_content
assert "Channel: cli" in runtime_content
assert "Chat ID: direct" in runtime_content
assert messages[-1]["role"] == "user"
assert messages[-1]["content"] == "Return exactly: OK"
+29
View File
@@ -0,0 +1,29 @@
from typer.testing import CliRunner
from nanobot.cli.commands import app
runner = CliRunner()
def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None:
monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path)
result = runner.invoke(
app,
[
"cron",
"add",
"--name",
"demo",
"--message",
"hello",
"--cron",
"0 9 * * *",
"--tz",
"America/Vancovuer",
],
)
assert result.exit_code == 1
assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout
assert not (tmp_path / "cron" / "jobs.json").exists()
+30
View File
@@ -0,0 +1,30 @@
import pytest
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"):
service.add_job(
name="tz typo",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"),
message="hello",
)
assert service.list_jobs(include_disabled=True) == []
def test_add_job_accepts_valid_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
job = service.add_job(
name="tz ok",
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
message="hello",
)
assert job.schedule.tz == "America/Vancouver"
assert job.state.next_run_at_ms is not None
+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"
+58 -1
View File
@@ -169,7 +169,8 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
@pytest.mark.asyncio
async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
"""When auto_reply_enabled=False, replies should be skipped but proactive sends allowed."""
class FakeSMTP:
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
self.sent_messages: list[EmailMessage] = []
@@ -201,6 +202,11 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
cfg = _make_config()
cfg.auto_reply_enabled = False
channel = EmailChannel(cfg, MessageBus())
# Mark alice as someone who sent us an email (making this a "reply")
channel._last_subject_by_chat["alice@example.com"] = "Previous email"
# Reply should be skipped (auto_reply_enabled=False)
await channel.send(
OutboundMessage(
channel="email",
@@ -210,6 +216,7 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
)
assert fake_instances == []
# Reply with force_send=True should be sent
await channel.send(
OutboundMessage(
channel="email",
@@ -222,6 +229,56 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
assert len(fake_instances[0].sent_messages) == 1
@pytest.mark.asyncio
async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> None:
"""Proactive emails (not replies) should be sent even when auto_reply_enabled=False."""
class FakeSMTP:
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
self.sent_messages: list[EmailMessage] = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def starttls(self, context=None):
return None
def login(self, _user: str, _pw: str):
return None
def send_message(self, msg: EmailMessage):
self.sent_messages.append(msg)
fake_instances: list[FakeSMTP] = []
def _smtp_factory(host: str, port: int, timeout: int = 30):
instance = FakeSMTP(host, port, timeout=timeout)
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
cfg = _make_config()
cfg.auto_reply_enabled = False
channel = EmailChannel(cfg, MessageBus())
# bob@example.com has never sent us an email (proactive send)
# This should be sent even with auto_reply_enabled=False
await channel.send(
OutboundMessage(
channel="email",
chat_id="bob@example.com",
content="Hello, this is a proactive email.",
)
)
assert len(fake_instances) == 1
assert len(fake_instances[0].sent_messages) == 1
sent = fake_instances[0].sent_messages[0]
assert sent["To"] == "bob@example.com"
@pytest.mark.asyncio
async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
class FakeSMTP:

Some files were not shown because too many files have changed in this diff Show More