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
14 changed files with 923 additions and 1691 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ ENV PATH="/root/.local/bin:${PATH}"
COPY pyproject.toml README.md LICENSE /app/
COPY nanobot/ /app/nanobot/
RUN uv pip install --system --no-cache --reinstall /app[mem0] psycopg2-binary
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
ENTRYPOINT ["nanobot"]
CMD ["gateway"]
+2 -28
View File
@@ -6,10 +6,7 @@ import platform
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.agent.memory import MemoryStore
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
from nanobot.agent.skills import SkillsLoader
@@ -23,20 +20,9 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
def __init__(self, workspace: Path, mem0_config: dict[str, Any] | None = None):
def __init__(self, workspace: Path):
self.workspace = workspace
# Choose memory backend based on config
if mem0_config and mem0_config.get("enabled") and HAS_MEM0:
self.memory = Mem0MemoryStore(workspace, config=mem0_config)
self.use_mem0 = True
logger.info("ContextBuilder using mem0 for semantic memory")
else:
if mem0_config and mem0_config.get("enabled"):
logger.warning("mem0 enabled but not installed, falling back to MEMORY.md")
self.memory = MemoryStore(workspace)
self.use_mem0 = False
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
@@ -166,18 +152,6 @@ visibility markers will be rejected."""
system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
# Add mem0 semantic memory context (if enabled)
if self.use_mem0 and channel and chat_id:
user_id = f"{channel}_{chat_id}"
memory_context = self.memory.get_memory_context(
query=current_message,
user_id=user_id,
limit=5
)
if memory_context:
system_prompt += f"\n\n{memory_context}"
messages.append({"role": "system", "content": system_prompt})
# History
+437 -765
View File
File diff suppressed because it is too large Load Diff
-430
View File
@@ -1,430 +0,0 @@
"""Mem0-powered memory system for intelligent semantic retrieval."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session
try:
from mem0 import Memory
from mem0.configs.base import MemoryConfig
HAS_MEM0 = True
except ImportError:
HAS_MEM0 = False
MemoryConfig = None # type: ignore
class Mem0MemoryStore:
"""
Enhanced memory store using mem0 for semantic search and automatic extraction.
Features:
- Multi-level memory (user, session, agent)
- Semantic search with embeddings
- Automatic memory extraction from conversations
- 90% token reduction vs full-context
- 91% faster responses
"""
def __init__(self, workspace: Path, config: dict[str, Any] | None = None):
if not HAS_MEM0:
raise ImportError(
"mem0 not installed. Install with: pip install mem0ai"
)
self.workspace = workspace
self.memory_dir = workspace / "memory"
self.memory_dir.mkdir(parents=True, exist_ok=True)
# Build custom extraction prompt tuned for nanobot conversations
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n"
# Initialize mem0 with optional config + custom prompt
# Extract only MemoryConfig-relevant fields
raw_config = config if config else {}
logger.debug(f"Mem0MemoryStore received config keys: {list(raw_config.keys())}")
mem0_cfg_dict = {}
for key in ("vector_store", "llm", "embedder", "graph_store", "version"):
if key in raw_config:
mem0_cfg_dict[key] = raw_config[key]
logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}")
logger.debug(f"Custom prompt length: {len(custom_prompt)} chars")
self.custom_prompt = custom_prompt
mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt
mem0_config = MemoryConfig(**mem0_cfg_dict)
logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}")
self.memory = Memory(config=mem0_config)
logger.info("Mem0 memory system initialized with custom nanobot prompt")
def search_memories(
self,
query: str,
user_id: str,
limit: int = 5,
session_id: str | None = None,
) -> list[dict[str, Any]]:
"""
Search for relevant memories using semantic search.
Args:
query: Search query (user's current message)
user_id: User identifier (e.g., "telegram_12345")
limit: Max number of memories to return
session_id: Optional session-specific memories
Returns:
List of memory dicts with 'memory' and 'score' keys
"""
try:
# Search user-level memories
user_memories = self.memory.search(
query=query,
user_id=user_id,
limit=limit
)
results = []
if user_memories and "results" in user_memories:
results.extend(user_memories["results"])
# Optionally search session-level memories
if session_id:
session_memories = self.memory.search(
query=query,
user_id=user_id,
metadata={"session_id": session_id},
limit=limit // 2 # Reserve half for session context
)
if session_memories and "results" in session_memories:
results.extend(session_memories["results"])
logger.debug(
f"Mem0 search: query='{query[:50]}...', found {len(results)} memories"
)
return results[:limit] # Limit total results
except Exception as e:
logger.error(f"Mem0 search failed: {e}")
return []
def add_conversation(
self,
messages: list[dict[str, Any]],
user_id: str,
session_id: str | None = None,
) -> None:
"""
Add conversation messages to memory for automatic extraction.
Args:
messages: List of message dicts with 'role' and 'content'
user_id: User identifier
session_id: Optional session identifier for session-level memories
"""
try:
metadata = {}
if session_id:
metadata["session_id"] = session_id
# mem0 automatically extracts and stores relevant facts
result = self.memory.add(
messages,
user_id=user_id,
metadata=metadata if metadata else None
)
facts_count = len(result.get("results", [])) if result else 0
logger.debug(
f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts"
)
except Exception as e:
logger.error(f"Mem0 add failed: {e}")
async def extract_facts(
self,
messages: list[dict[str, Any]],
provider: Any,
model: str,
) -> list[str]:
"""
Extract facts from conversation using the main agent's LLM provider.
Uses the same provider/model already running (e.g. Haiku via Claude Max),
avoiding a separate LLM call to mem0's default GPT-nano.
"""
import json as _json
# Build conversation text for extraction
conv_text = ""
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
conv_text += f"{role}: {content}\n\n"
if not conv_text.strip():
return []
extraction_messages = [
{"role": "user", "content": self.custom_prompt + conv_text}
]
try:
response = await provider.chat(
messages=extraction_messages,
model=model,
max_tokens=2000,
temperature=0.3,
)
# Parse the JSON response — LLMResponse.content is a string
text = response.content or ""
# Strip markdown code fences if present
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = _json.loads(text)
facts = data.get("facts", [])
logger.debug(f"Extracted {len(facts)} facts using {model}")
return facts
except Exception as e:
logger.error(f"Fact extraction failed: {e}")
return []
def store_facts(
self,
facts: list[str],
user_id: str,
session_id: str | None = None,
) -> None:
"""
Store pre-extracted facts in mem0 with infer=False.
Bypasses mem0's built-in LLM extraction — facts are already
in final form from extract_facts().
"""
if not facts:
return
metadata = {}
if session_id:
metadata["session_id"] = session_id
stored = 0
for fact in facts:
try:
self.memory.add(
fact,
user_id=user_id,
infer=False,
metadata=metadata if metadata else None,
)
stored += 1
except Exception as e:
logger.error(f"Failed to store fact '{fact[:50]}...': {e}")
logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}")
def get_memory_context(
self,
query: str,
user_id: str,
limit: int = 5
) -> str:
"""
Get formatted memory context for inclusion in system prompt.
Args:
query: Current user query
user_id: User identifier
limit: Max memories to include
Returns:
Formatted memory context string
"""
memories = self.search_memories(query, user_id, limit=limit)
if not memories:
return ""
lines = ["## Relevant Memories"]
for i, mem in enumerate(memories, 1):
memory_text = mem.get("memory", "")
# Include score if available for debugging
score = mem.get("score", "")
score_str = f" (relevance: {score:.2f})" if score else ""
lines.append(f"{i}. {memory_text}{score_str}")
return "\n".join(lines)
def update_memory(self, memory_id: str, data: dict[str, Any]) -> None:
"""Update a specific memory by ID."""
try:
self.memory.update(memory_id, data)
logger.debug(f"Mem0 update: memory_id={memory_id}")
except Exception as e:
logger.error(f"Mem0 update failed: {e}")
def delete_memory(self, memory_id: str) -> None:
"""Delete a specific memory by ID."""
try:
self.memory.delete(memory_id)
logger.debug(f"Mem0 delete: memory_id={memory_id}")
except Exception as e:
logger.error(f"Mem0 delete failed: {e}")
def get_all_memories(self, user_id: str) -> list[dict[str, Any]]:
"""Get all memories for a user."""
try:
result = self.memory.get_all(user_id=user_id)
return result.get("results", []) if result else []
except Exception as e:
logger.error(f"Mem0 get_all failed: {e}")
return []
async def consolidate(
self,
session: Session,
provider: LLMProvider,
model: str,
*,
archive_all: bool = False,
memory_window: int = 50,
) -> bool:
"""
Consolidate session messages into mem0 memory.
Facts are extracted using the main agent's LLM provider, then stored with infer=False.
Returns True on success.
"""
try:
# Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345")
user_id = session.key.replace(":", "_")
# Determine which messages to consolidate
if archive_all:
messages_to_add = session.messages
logger.info(
f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages"
)
else:
keep_count = memory_window // 2
if len(session.messages) <= keep_count:
return True
# Get unconsolidated messages
start_idx = session.last_consolidated
end_idx = len(session.messages) - keep_count
if end_idx <= start_idx:
return True
messages_to_add = session.messages[start_idx:end_idx]
if not messages_to_add:
return True
logger.info(
f"Mem0 consolidation: {len(messages_to_add)} to consolidate, "
f"{keep_count} keep"
)
# Convert to mem0 format with intelligent filtering
mem0_messages = []
for msg in messages_to_add:
role = msg.get("role")
content = msg.get("content")
# Keep tool results but truncate long ones — they often contain
# the actual substance (file reads, search results, web pages).
# The extraction prompt handles ignoring code/JSON noise.
if role == "tool":
if isinstance(content, list):
text_parts = [
block.get("content", "") if isinstance(block, dict) else str(block)
for block in content
]
content = " ".join(text_parts).strip()
if isinstance(content, str) and len(content) > 2000:
content = content[:2000]
if not content or (isinstance(content, str) and len(content.strip()) < 10):
continue
mem0_messages.append({"role": "user", "content": content})
continue
# Skip system messages — they're boilerplate instructions, not facts
if role == "system":
continue
# Skip messages with no content
if not content:
continue
# Normalize assistant message content: extract text from Anthropic list format
if role == "assistant" and isinstance(content, list):
# Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks
text_parts = [
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text"
]
content = " ".join(text_parts).strip()
if not content:
continue # Skip if assistant only called tools with no text explanation
# Normalize user message content (could also be a list in some formats)
if isinstance(content, list):
text_parts = [
block.get("text", "") if isinstance(block, dict) else str(block)
for block in content
]
content = " ".join(text_parts).strip()
if not content:
continue
# Skip trivially short messages (commands like "/new")
if len(content.strip()) < 10:
continue
mem0_messages.append({
"role": role,
"content": content
})
if mem0_messages:
# Extract facts using the main agent's LLM (already paid for),
# then store with infer=False to bypass mem0's GPT-nano
facts = await self.extract_facts(mem0_messages, provider, model)
self.store_facts(facts, user_id=user_id, session_id=session.key)
# Update consolidation marker
if archive_all:
session.last_consolidated = len(session.messages)
else:
session.last_consolidated = end_idx
logger.info(
f"Mem0 consolidation done: {len(session.messages)} messages, "
f"last_consolidated={session.last_consolidated}"
)
return True
except Exception:
logger.exception("Mem0 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 skill metadata JSON from frontmatter (supports nanobot, clawdbot, and openclaw keys)."""
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
return (data.get("nanobot") or data.get("clawdbot") or data.get("openclaw") or {}) 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 {}
+3 -1
View File
@@ -1,7 +1,9 @@
"""Async message queue for decoupled channel-agent communication."""
import asyncio
from typing import Awaitable, Callable
from typing import Callable, Awaitable
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
+3 -3
View File
@@ -210,15 +210,15 @@ class ChannelManager:
timeout=1.0
)
# Resolve any pending correlation (hook request-response)
self.bus.resolve_correlation(msg)
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:
+375 -265
View File
@@ -2,26 +2,25 @@
import asyncio
import os
import select
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
import select
import sys
import typer
from loguru import logger
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
from nanobot.cli.oauth import oauth_app
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
app = typer.Typer(
name="nanobot",
@@ -158,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
@@ -166,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:")
@@ -191,98 +198,20 @@ 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.
## 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):
def _make_provider(config: Config):
"""Create LLM provider from config. Uses OAuth for subscription tokens."""
from nanobot.providers import create_provider
p = config.get_provider()
model = config.agents.defaults.model
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 create_provider(
api_key=p.api_key if p else None,
api_key=p.api_key if p else "",
model=model,
api_base=config.get_api_base(),
extra_headers=p.extra_headers if p else None,
@@ -296,40 +225,20 @@ def _make_provider(config):
# ============================================================================
def _start_moltbook_loop():
"""Start the moltbook polling loop in the background."""
loop_script = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.sh"
log_file = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.log"
if not loop_script.exists():
return
try:
subprocess.Popen(
["/bin/bash", str(loop_script)],
stdout=open(log_file, "a"),
stderr=subprocess.STDOUT,
start_new_session=True,
)
console.print(f"[green]✓[/green] Moltbook polling: every 15m")
except Exception as e:
console.print(f"[yellow]Warning: Could not start moltbook loop: {e}[/yellow]")
@app.command()
def gateway(
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the nanobot gateway."""
from nanobot.agent.loop import AgentLoop
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.config.loader import get_data_dir, load_config
from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose:
import logging
@@ -338,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)
@@ -346,37 +256,14 @@ def gateway(
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
# Convert mem0 config to dict for AgentLoop
mem0_config = None
if config.tools.mem0.enabled:
mem0_config = {
"enabled": True,
"search_limit": config.tools.mem0.search_limit,
}
if config.tools.mem0.api_key:
mem0_config["api_key"] = config.tools.mem0.api_key
if config.tools.mem0.llm:
mem0_config["llm"] = {
"provider": "openai",
"config": {"model": config.tools.mem0.llm}
}
if config.tools.mem0.embedder:
mem0_config["embedder"] = {
"provider": "openai",
"config": {"model": config.tools.mem0.embedder}
}
if config.tools.mem0.vector_store:
mem0_config["vector_store"] = config.tools.mem0.vector_store
# DEBUG: Log what's being passed
logger.debug(f"Passing mem0_config to AgentLoop: {list(mem0_config.keys())}")
# Create agent with cron service
agent = 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,
@@ -384,8 +271,9 @@ def gateway(
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
mem0_config=mem0_config,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
)
# Set cron callback (needs agent)
@@ -407,50 +295,59 @@ def gateway(
return response
cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str, metadata: dict[str, Any] | None = None) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(
prompt,
session_key="telegram:239824268", # Run in main telegram session
channel="telegram",
chat_id="239824268",
metadata=metadata,
)
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True,
session_manager=session_manager, # Pass session manager
target_session_key="telegram:239824268", # Target session
idle_threshold_s=20 * 60, # 20 minutes idle
)
# Create channel manager
channels = ChannelManager(config, bus)
# Create hooks server
from nanobot.channels.hook import HookChannel
from nanobot.hooks.server import HooksServer
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"
hooks_config = config.hooks if hasattr(config, 'hooks') else None
hooks_server = None
# 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()
if hooks_config and hooks_config.enabled:
# Register hook channel
hook_channel = HookChannel(bus)
channels.register_channel("hook", hook_channel)
async def _silent(*_args, **_kwargs):
pass
# Create hooks server (checks has_tokens internally)
hooks_server = HooksServer(
host=config.gateway.host,
port=config.gateway.port,
config=hooks_config,
bus=bus,
return await agent.process_direct(
tasks,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
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)}")
@@ -461,24 +358,20 @@ def gateway(
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print("[green]✓[/green] Heartbeat: every 30m")
_start_moltbook_loop()
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
async def run():
try:
await cron.start()
await heartbeat.start()
if hooks_server:
await hooks_server.start()
await asyncio.gather(
agent.run(),
channels.start_all(),
)
except KeyboardInterrupt:
console.print("\nShutting down...")
if hooks_server:
await hooks_server.stop()
finally:
await agent.close_mcp()
heartbeat.stop()
cron.stop()
agent.stop()
@@ -502,56 +395,43 @@ 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, 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
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
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:
logger.disable("nanobot")
# Convert mem0 config to dict for AgentLoop
mem0_config = None
if config.tools.mem0.enabled:
mem0_config = {
"enabled": True,
"search_limit": config.tools.mem0.search_limit,
}
if config.tools.mem0.api_key:
mem0_config["api_key"] = config.tools.mem0.api_key
if config.tools.mem0.llm:
mem0_config["llm"] = {
"provider": "openai",
"config": {"model": config.tools.mem0.llm}
}
if config.tools.mem0.embedder:
mem0_config["embedder"] = {
"provider": "openai",
"config": {"model": config.tools.mem0.embedder}
}
if config.tools.mem0.vector_store:
mem0_config["vector_store"] = config.tools.mem0.vector_store
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,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
enable_memory_tool=config.tools.enable_memory_tool,
mem0_config=mem0_config,
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
@@ -562,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!")
@@ -583,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())
@@ -619,8 +564,6 @@ def agent(
channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")
app.add_typer(oauth_app, name="oauth")
@channels_app.command("status")
def channels_status():
@@ -685,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)
@@ -750,7 +720,6 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
from nanobot.config.loader import load_config
config = load_config()
@@ -804,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]"
@@ -832,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"),
@@ -842,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)
@@ -858,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})")
@@ -913,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("[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]")
@@ -936,7 +961,7 @@ def cron_run(
@app.command()
def status():
"""Show nanobot status."""
from nanobot.config.loader import get_config_path, load_config
from nanobot.config.loader import load_config, get_config_path
config_path = get_config_path()
config = load_config()
@@ -957,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]")
@@ -968,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()
+3 -15
View File
@@ -1,7 +1,7 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
from typing import Any, Literal
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel
@@ -310,7 +310,7 @@ class GatewayConfig(Base):
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
class HooksConfig(BaseModel):
class HooksConfig(Base):
"""Webhook endpoint configuration."""
enabled: bool = False
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
@@ -330,7 +330,7 @@ class HooksConfig(BaseModel):
return bool(self.tokens)
class WebSearchConfig(BaseModel):
class WebSearchConfig(Base):
"""Web search tool configuration."""
api_key: str = "" # Brave Search API key
@@ -361,17 +361,6 @@ class MCPServerConfig(Base):
tool_timeout: int = 30 # Seconds before a tool call is cancelled
class Mem0Config(Base):
"""Mem0 memory system configuration."""
enabled: bool = False # If true, use mem0 for semantic memory instead of simple MEMORY.md
api_key: str = "" # Optional: mem0 cloud API key (leave empty for self-hosted)
search_limit: int = 5 # Max memories to retrieve per query
llm: str = "" # Optional: LLM for memory extraction (default: gpt-4.1-nano-2025-04-14)
embedder: str = "" # Optional: Embedding model (default: mem0's default)
vector_store: dict[str, Any] = Field(default_factory=dict) # Vector store config (e.g., {"provider": "qdrant", "config": {...}})
class ToolsConfig(Base):
"""Tools configuration."""
@@ -379,7 +368,6 @@ class ToolsConfig(Base):
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
mem0: Mem0Config = Field(default_factory=Mem0Config) # Mem0 semantic memory configuration
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
+1 -8
View File
@@ -316,11 +316,6 @@ class AnthropicOAuthProvider(LLMProvider):
headers.get("anthropic-beta", "none"),
)
# Debug: Log tool names for diagnostic purposes
if payload.get("tools"):
tool_names = [t.get("name", "unnamed") for t in payload["tools"]]
logger.debug(f"Tool names in request: {tool_names}")
response = await client.post(
self._get_api_url(),
headers=headers,
@@ -417,10 +412,8 @@ class AnthropicOAuthProvider(LLMProvider):
)
return self._parse_response(response)
except Exception as e:
logger.exception("Exception in chat():")
error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__} (no message)"
return LLMResponse(
content=f"Error calling LLM: {error_msg}",
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
+34 -40
View File
@@ -1,7 +1,6 @@
"""Session management for conversation history."""
import json
import shutil
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
@@ -18,10 +17,6 @@ class Session:
A conversation session.
Stores messages in JSONL format for easy reading and persistence.
Important: Messages are append-only for LLM cache efficiency.
The consolidation process writes summaries to MEMORY.md/HISTORY.md
but does NOT modify the messages list or get_history() output.
"""
key: str # channel:chat_id
@@ -29,7 +24,6 @@ class Session:
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session."""
@@ -71,9 +65,8 @@ class Session:
]
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
"""Clear all messages in the session."""
self.messages = []
self.last_consolidated = 0
self.updated_at = datetime.now()
@@ -86,8 +79,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = ensure_dir(self.workspace / "sessions")
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
@@ -95,11 +87,6 @@ class SessionManager:
safe_key = safe_filename(key.replace(":", "_"))
return self.sessions_dir / f"{safe_key}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_"))
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session:
"""
Get an existing session or create a new one.
@@ -110,9 +97,11 @@ class SessionManager:
Returns:
The session.
"""
# Check cache
if key in self._cache:
return self._cache[key]
# Try to load from disk
session = self._load(key)
if session is None:
session = Session(key=key)
@@ -123,14 +112,6 @@ class SessionManager:
def _load(self, key: str) -> Session | None:
"""Load a session from disk."""
path = self._get_session_path(key)
if not path.exists():
legacy_path = self._get_legacy_session_path(key)
if legacy_path.exists():
try:
shutil.move(str(legacy_path), str(path))
logger.info("Migrated session {} from legacy path", key)
except Exception:
logger.exception("Failed to migrate session {}", key)
if not path.exists():
return None
@@ -139,9 +120,8 @@ class SessionManager:
messages = []
metadata = {}
created_at = None
last_consolidated = 0
with open(path, encoding="utf-8") as f:
with open(path) as f:
for line in f:
line = line.strip()
if not line:
@@ -152,7 +132,6 @@ class SessionManager:
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
@@ -160,36 +139,52 @@ class SessionManager:
key=key,
messages=messages,
created_at=created_at or datetime.now(),
metadata=metadata,
last_consolidated=last_consolidated
metadata=metadata
)
except Exception as e:
logger.warning("Failed to load session {}: {}", key, e)
logger.warning(f"Failed to load session {key}: {e}")
return None
def save(self, session: Session) -> None:
"""Save a session to disk."""
path = self._get_session_path(session.key)
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
# Write metadata first
metadata_line = {
"_type": "metadata",
"key": session.key,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
"last_consolidated": session.last_consolidated
"metadata": session.metadata
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
f.write(json.dumps(metadata_line) + "\n")
# Write messages
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
f.write(json.dumps(msg) + "\n")
self._cache[session.key] = session
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
def delete(self, key: str) -> bool:
"""
Delete a session.
Args:
key: Session key.
Returns:
True if deleted, False if not found.
"""
# Remove from cache
self._cache.pop(key, None)
# Remove file
path = self._get_session_path(key)
if path.exists():
path.unlink()
return True
return False
def list_sessions(self) -> list[dict[str, Any]]:
"""
List all sessions.
@@ -202,14 +197,13 @@ class SessionManager:
for path in self.sessions_dir.glob("*.jsonl"):
try:
# Read just the metadata line
with open(path, encoding="utf-8") as f:
with open(path) as f:
first_line = f.readline().strip()
if first_line:
data = json.loads(first_line)
if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1)
sessions.append({
"key": key,
"key": path.stem.replace("_", ":"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"path": str(path)
-3
View File
@@ -47,9 +47,6 @@ dev = [
"pytest-asyncio>=0.21.0",
"ruff>=0.1.0",
]
mem0 = [
"mem0ai>=0.1.0",
]
[project.scripts]
nanobot = "nanobot.cli.commands:app"
-68
View File
@@ -1,68 +0,0 @@
"""Test that bash tool handles heredoc commands correctly.
Reproduces the bug where `; echo '<<exit>>'` appended on the same line
as a heredoc terminator prevents bash from recognizing the terminator,
causing the session to hang forever.
"""
import asyncio
import pytest
from nanobot.agent.tools.anthropic.bash import BashTool20250124
@pytest.mark.asyncio
async def test_heredoc_command():
"""Heredoc commands must complete without hanging."""
tool = BashTool20250124()
# Simple command works
result = await tool(command="echo hello")
assert result.output == "hello"
# Heredoc command — this is the exact pattern that caused the hang
result = await asyncio.wait_for(
tool(command="cat << 'EOF'\nline1\nline2\nEOF"),
timeout=5.0,
)
assert "line1" in result.output
assert "line2" in result.output
@pytest.mark.asyncio
async def test_heredoc_append_to_file():
"""Heredoc append (the exact pattern the LLM uses) must work."""
tool = BashTool20250124()
result = await asyncio.wait_for(
tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"),
timeout=5.0,
)
# Should complete without error
assert result.error is None or result.error == ""
# Verify the file was written
result2 = await tool(command="cat /tmp/test_heredoc_bash.txt")
assert "hello world" in result2.output
# Cleanup
await tool(command="rm -f /tmp/test_heredoc_bash.txt")
@pytest.mark.asyncio
async def test_regular_commands_still_work():
"""Ensure regular commands still work after the fix."""
tool = BashTool20250124()
# Semicolons in commands
result = await tool(command="echo a; echo b")
assert "a" in result.output
assert "b" in result.output
# Multiline script
result = await tool(command="for i in 1 2 3; do echo $i; done")
assert "1" in result.output
assert "3" in result.output
# Command with exit code
result = await tool(command="true")
assert result.output == "(no output)" or result.output is not None
+1 -1
View File
@@ -40,7 +40,7 @@ async def test_bash_tool_restart():
# Restart
result = await tool(restart=True)
assert "restarted" in (result.system or result.output or "").lower()
assert "restarted" in result.output.lower()
# Variable should be gone
result2 = await tool(command="echo $TEST_VAR")