Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8c910ab1 | ||
|
|
cc10e20a47 | ||
|
|
34ed4345fc | ||
|
|
1a85333e4c | ||
|
|
3c587c788a | ||
|
|
303d123527 | ||
|
|
61c2cb4ac4 | ||
|
|
3126b99fdb | ||
|
|
b28b647ce3 | ||
|
|
d736f1cf46 | ||
|
|
e4402f2f83 | ||
|
|
dc5d8edfec | ||
|
|
bdc3be650b | ||
|
|
119de1f347 | ||
|
|
88f6ecea5a | ||
|
|
a0eb6e9dcf | ||
|
|
c987976f82 | ||
|
|
8116848670 | ||
|
|
f6412b8349 | ||
|
|
f1023d9573 | ||
|
|
39560524f7 | ||
|
|
aec8510d49 | ||
|
|
9dd9c0a4be | ||
|
|
53391762be | ||
|
|
d2487ec6a3 | ||
|
|
8e7c6db4b5 | ||
|
|
a16020c4a2 | ||
|
|
83d6e3cd65 | ||
|
|
80e56294f6 | ||
|
|
a1823004aa | ||
|
|
54255c89c4 | ||
|
|
9a7596193f | ||
|
|
31a889f9fd | ||
|
|
6c0d68cfbb | ||
|
|
5234e76c40 | ||
|
|
f048e8cbec | ||
|
|
9e8d2d4a09 | ||
|
|
aa4393b2eb | ||
|
|
9fcb1dc4c0 | ||
|
|
c5f78bf17e | ||
|
|
6003777eda | ||
|
|
bbfb4a0c2c | ||
|
|
b44c94c9cf | ||
|
|
0e16f1bc3d | ||
|
|
83fc393359 | ||
|
|
2e63a09150 | ||
|
|
16791c9717 | ||
|
|
a770d8b0f9 | ||
|
|
f148ffd7a4 | ||
|
|
03d3e3a4da | ||
|
|
6c13a7e722 | ||
|
|
6adefbf190 | ||
|
|
45a377030f | ||
|
|
6165f523c3 | ||
|
|
3c760be0b2 | ||
|
|
b322cd66f2 | ||
|
|
9bb9cd0d55 | ||
|
|
ae1dc44705 | ||
|
|
548179ed3b | ||
|
|
88828112b5 | ||
|
|
e9a221bdd9 | ||
|
|
003c8dbf59 | ||
|
|
51b5e02948 | ||
|
|
97ad118615 | ||
|
|
5d7d526ca0 | ||
|
|
d987d04606 | ||
|
|
e99a47608d | ||
|
|
d584c11b6f | ||
|
|
5de628434c | ||
|
|
fd8365992d | ||
|
|
0fb9504920 | ||
|
|
6e868cb712 | ||
|
|
0ca40f1929 | ||
|
|
195483c65f | ||
|
|
e140b850b4 | ||
|
|
b4c6c4e5ef | ||
|
|
b0e2033ded | ||
|
|
d095bd3cb8 | ||
|
|
5ef45c4345 | ||
|
|
215637a93c | ||
|
|
c6b68f0b6b | ||
|
|
5317bf869b | ||
|
|
0ea1af4ebf | ||
|
|
ca56d15dc9 | ||
|
|
51f38af9eb | ||
|
|
dbd4786b49 | ||
|
|
a2788023a1 | ||
|
|
ba5863f34c | ||
|
|
513582720a | ||
|
|
8e7e94e424 | ||
|
|
31eae748f6 | ||
|
|
ad2d5d2e8f | ||
|
|
a27220dbd0 | ||
|
|
326f18f8a8 | ||
|
|
6f2ff279ae | ||
|
|
41a3366f3e | ||
|
|
5eba972737 | ||
|
|
dafaa3bab4 | ||
|
|
2059acb3a4 | ||
|
|
a8a075600e | ||
|
|
471fd08fba | ||
|
|
1d30c3f6ce | ||
|
|
f959185bca | ||
|
|
1381735e3b | ||
|
|
6612576f8f | ||
|
|
727ffa2943 | ||
|
|
8dc66c713a | ||
|
|
ca8376c4a6 | ||
|
|
e1987c7fa5 | ||
|
|
a267110ce3 | ||
|
|
c00979a3b8 | ||
|
|
171c18eb5a | ||
|
|
5924017c39 | ||
|
|
c34dd1c90f | ||
|
|
891b85403d | ||
|
|
ecf1029b08 | ||
|
|
45294c9cc6 | ||
|
|
c0d7107c07 | ||
|
|
4bec87c1e7 | ||
|
|
422cccf091 | ||
|
|
1579b6e052 | ||
|
|
9e3f4ad5cc | ||
|
|
001cec88c6 | ||
|
|
9854edf113 | ||
|
|
3b08980b9d | ||
|
|
75e840e831 | ||
|
|
95982b62ef | ||
|
|
6bf81753cc | ||
|
|
18482c72e4 | ||
|
|
19a81e1b05 | ||
|
|
73539ef1b8 | ||
|
|
47954fe260 | ||
|
|
38a1c7f838 | ||
|
|
bd03c151da | ||
|
|
8bbe412848 | ||
|
|
d26582c5dc | ||
|
|
b62de7799c | ||
|
|
82ca557b47 | ||
|
|
7fd1017a53 | ||
|
|
c4e78f4d80 | ||
|
|
05f4464935 | ||
|
|
ca9da38a92 | ||
|
|
a541817054 | ||
|
|
37e478a3e2 | ||
|
|
4e94e0a422 | ||
|
|
2d8a5de7b9 | ||
|
|
d6aaff511e | ||
|
|
fe74da53e2 | ||
|
|
dfb81b45a7 | ||
|
|
22629634c9 | ||
|
|
b507630f3b | ||
|
|
68a9b7ad7d | ||
|
|
536ede12de | ||
|
|
6fed0e5e2d | ||
|
|
283a1fbefd | ||
|
|
5b1bc3a47d | ||
|
|
aaf4de23cc | ||
|
|
f639364d7f | ||
|
|
4b0fb7bdbe | ||
|
|
5111c69ff9 | ||
|
|
12e1470506 | ||
|
|
8cb5dd28e6 | ||
|
|
44dc549f75 | ||
|
|
ee2bf70f42 |
+1
-1
@@ -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 psycopg2-binary
|
||||
RUN uv pip install --system --no-cache --reinstall /app[mem0] psycopg2-binary
|
||||
|
||||
ENTRYPOINT ["nanobot"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -6,7 +6,10 @@ 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
|
||||
|
||||
|
||||
@@ -20,9 +23,20 @@ class ContextBuilder:
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
def __init__(self, workspace: Path, mem0_config: dict[str, Any] | None = None):
|
||||
self.workspace = workspace
|
||||
self.memory = MemoryStore(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.skills = SkillsLoader(workspace)
|
||||
|
||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||
@@ -152,6 +166,18 @@ 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
|
||||
|
||||
+834
-408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
"""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")
|
||||
# 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
|
||||
@@ -167,10 +167,10 @@ class SkillsLoader:
|
||||
return content
|
||||
|
||||
def _parse_nanobot_metadata(self, raw: str) -> dict:
|
||||
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
|
||||
"""Parse skill metadata JSON from frontmatter (supports nanobot, clawdbot, and openclaw keys)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return (data.get("nanobot") or data.get("openclaw") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
|
||||
return (data.get("nanobot") or data.get("clawdbot") or data.get("openclaw") or {}) if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1,114 +1,117 @@
|
||||
"""BashTool20250124 - Persistent bash session with sentinel-based output.
|
||||
"""BashTool20250124 - Persistent bash session with async buffer polling.
|
||||
|
||||
Anthropic's native bash_20250124 tool with a long-running session.
|
||||
Based on Anthropic's reference implementation from anthropic-quickstarts.
|
||||
Uses asyncio.create_subprocess_shell + direct buffer reads instead of
|
||||
threaded readline, which avoids exhausting the default ThreadPoolExecutor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult, ToolError
|
||||
|
||||
|
||||
class _BashSession:
|
||||
"""Manages a persistent bash subprocess with sentinel-based output reading."""
|
||||
"""A session of a bash shell.
|
||||
|
||||
Uses asyncio subprocess with direct buffer polling — no threads.
|
||||
Based on anthropics/anthropic-quickstarts computer-use-demo.
|
||||
"""
|
||||
|
||||
command: str = "/bin/bash"
|
||||
_output_delay: float = 0.2 # seconds between buffer polls
|
||||
_timeout: float = 120.0 # seconds
|
||||
_sentinel: str = "<<exit>>"
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
self._start()
|
||||
self._started = False
|
||||
self._timed_out = False
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
def _start(self):
|
||||
"""Start the bash process."""
|
||||
self.process = subprocess.Popen(
|
||||
["bash"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
async def start(self):
|
||||
if self._started:
|
||||
return
|
||||
|
||||
self._process = await asyncio.create_subprocess_shell(
|
||||
self.command,
|
||||
preexec_fn=os.setsid,
|
||||
shell=True,
|
||||
bufsize=0,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._started = True
|
||||
|
||||
def restart(self):
|
||||
"""Restart the bash session."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self._start()
|
||||
def stop(self):
|
||||
"""Terminate the bash shell."""
|
||||
if not self._started:
|
||||
return
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.terminate()
|
||||
|
||||
async def run_command(self, command: str, timeout: float = 120.0) -> str:
|
||||
"""Run a command in the persistent bash session.
|
||||
async def run(self, command: str) -> ToolResult:
|
||||
"""Execute a command in the bash shell."""
|
||||
if not self._started:
|
||||
raise ToolError("Session has not started.")
|
||||
if self._process is None or self._process.returncode is not None:
|
||||
return ToolResult(
|
||||
system="tool must be restarted",
|
||||
error=f"bash has exited with returncode "
|
||||
f"{self._process.returncode if self._process else 'unknown'}",
|
||||
)
|
||||
if self._timed_out:
|
||||
raise ToolError(
|
||||
f"timed out: bash has not returned in {self._timeout} seconds "
|
||||
"and must be restarted",
|
||||
)
|
||||
|
||||
Uses a unique sentinel to detect command completion.
|
||||
assert self._process.stdin
|
||||
assert self._process.stdout
|
||||
assert self._process.stderr
|
||||
|
||||
Args:
|
||||
command: Bash command to execute
|
||||
timeout: Maximum time to wait for command completion (seconds)
|
||||
# Send command + sentinel on its own line so heredoc terminators
|
||||
# aren't corrupted (EOF; echo '...' ≠ EOF)
|
||||
self._process.stdin.write(
|
||||
command.encode() + f"\necho '{self._sentinel}'\n".encode()
|
||||
)
|
||||
await self._process.stdin.drain()
|
||||
|
||||
Returns:
|
||||
Command output (stdout + stderr combined)
|
||||
# Poll stdout buffer until sentinel appears — no threads involved
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout):
|
||||
while True:
|
||||
await asyncio.sleep(self._output_delay)
|
||||
output = self._process.stdout._buffer.decode()
|
||||
if self._sentinel in output:
|
||||
output = output[: output.index(self._sentinel)]
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
self._timed_out = True
|
||||
raise ToolError(
|
||||
f"timed out: bash has not returned in {self._timeout} seconds "
|
||||
"and must be restarted",
|
||||
) from None
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If command doesn't complete within timeout
|
||||
RuntimeError: If bash process has died
|
||||
"""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
raise RuntimeError("Bash process has died")
|
||||
if output.endswith("\n"):
|
||||
output = output[:-1]
|
||||
|
||||
# Generate unique sentinel
|
||||
sentinel = f"<<BASH_COMMAND_DONE_{uuid.uuid4().hex}>>"
|
||||
error = self._process.stderr._buffer.decode()
|
||||
if error.endswith("\n"):
|
||||
error = error[:-1]
|
||||
|
||||
# Send command + sentinel
|
||||
full_command = f"{command}\necho '{sentinel}'\n"
|
||||
self.process.stdin.write(full_command)
|
||||
self.process.stdin.flush()
|
||||
# Clear buffers for next command
|
||||
self._process.stdout._buffer.clear()
|
||||
self._process.stderr._buffer.clear()
|
||||
|
||||
# Read output until sentinel appears
|
||||
output_lines = []
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
# Check timeout
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise asyncio.TimeoutError(
|
||||
f"Command timed out after {timeout}s: {command[:50]}..."
|
||||
)
|
||||
|
||||
# Read line (non-blocking via asyncio)
|
||||
try:
|
||||
line = await asyncio.wait_for(
|
||||
asyncio.to_thread(self.process.stdout.readline),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# No output yet, continue waiting
|
||||
continue
|
||||
|
||||
if not line:
|
||||
# EOF - process died
|
||||
raise RuntimeError("Bash process terminated unexpectedly")
|
||||
|
||||
# Check for sentinel
|
||||
if sentinel in line:
|
||||
break
|
||||
|
||||
output_lines.append(line.rstrip("\n"))
|
||||
|
||||
return "\n".join(output_lines)
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up bash process on deletion."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
# Return as ToolResult (our loop handles this type)
|
||||
if error and output:
|
||||
return ToolResult(output=f"{output}\n\nstderr: {error}")
|
||||
elif error:
|
||||
return ToolResult(output=error)
|
||||
else:
|
||||
return ToolResult(output=output if output else "(no output)")
|
||||
|
||||
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
@@ -124,10 +127,10 @@ class BashTool20250124(BaseAnthropicTool):
|
||||
|
||||
api_type: Literal["bash_20250124"] = "bash_20250124"
|
||||
name: Literal["bash"] = "bash"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
beta_flag: str | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._session = _BashSession()
|
||||
self._session: _BashSession | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -135,39 +138,26 @@ class BashTool20250124(BaseAnthropicTool):
|
||||
restart: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> ToolResult:
|
||||
"""Execute bash command or restart session.
|
||||
|
||||
Args:
|
||||
command: Bash command to execute (optional)
|
||||
restart: Restart the bash session (optional)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
ToolResult with command output or error
|
||||
"""
|
||||
if restart:
|
||||
self._session.restart()
|
||||
return ToolResult(output="Bash session restarted successfully.")
|
||||
if self._session:
|
||||
self._session.stop()
|
||||
self._session = _BashSession()
|
||||
await self._session.start()
|
||||
return ToolResult(system="tool has been restarted.")
|
||||
|
||||
if not command:
|
||||
return ToolResult(
|
||||
error="Either 'command' or 'restart=True' must be provided."
|
||||
)
|
||||
if self._session is None:
|
||||
self._session = _BashSession()
|
||||
await self._session.start()
|
||||
|
||||
try:
|
||||
output = await self._session.run_command(command)
|
||||
return ToolResult(output=output if output else "(no output)")
|
||||
except asyncio.TimeoutError as e:
|
||||
return ToolResult(error=f"Command timed out: {e}")
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"{e}")
|
||||
if command is not None:
|
||||
try:
|
||||
return await self._session.run(command)
|
||||
except ToolError as e:
|
||||
return ToolResult(error=str(e))
|
||||
|
||||
return ToolResult(error="Either 'command' or 'restart=True' must be provided.")
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API with bash_20250124 type
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
|
||||
@@ -67,13 +67,14 @@ class ComputerTool20251124(BaseAnthropicTool):
|
||||
self.display_height_px = display_height_px
|
||||
|
||||
def to_params(self):
|
||||
"""Return tool definition for API."""
|
||||
"""Return tool definition for API.
|
||||
|
||||
NOTE: display_width_px, display_height_px, and enable_zoom are NOT
|
||||
valid parameters for computer_20251124 and cause API hangs if sent.
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
"display_width_px": self.display_width_px,
|
||||
"display_height_px": self.display_height_px,
|
||||
"enable_zoom": True,
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
|
||||
@@ -20,7 +20,7 @@ class EditTool20250728(BaseAnthropicTool):
|
||||
|
||||
api_type: Literal["text_editor_20250728"] = "text_editor_20250728"
|
||||
name: Literal["str_replace_based_edit_tool"] = "str_replace_based_edit_tool"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
beta_flag: str | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Mem0 memory tools — expose semantic memory to the agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore
|
||||
|
||||
|
||||
class Mem0ToolContext:
|
||||
"""Shared mutable state injected into every mem0 tool."""
|
||||
|
||||
def __init__(self, store: Mem0MemoryStore, consolidate_fn):
|
||||
self.store = store
|
||||
self.consolidate_fn = consolidate_fn # async (session, archive_all) -> None
|
||||
self.user_id: str = "unknown"
|
||||
self.session = None
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, session=None):
|
||||
self.user_id = f"{channel}_{chat_id}"
|
||||
self.session = session
|
||||
|
||||
|
||||
class MemorySearchTool(Tool):
|
||||
"""Search memories semantically."""
|
||||
|
||||
name = "memory_search"
|
||||
description = (
|
||||
"Search your long-term memory for facts relevant to a query. "
|
||||
"Returns the most relevant memories ranked by similarity."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural-language search query",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default 5)",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, query: str, limit: int = 5, **kw: Any) -> str:
|
||||
results = self._ctx.store.search_memories(
|
||||
query=query,
|
||||
user_id=self._ctx.user_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not results:
|
||||
return "No memories found."
|
||||
lines = []
|
||||
for i, mem in enumerate(results, 1):
|
||||
text = mem.get("memory", "")
|
||||
score = mem.get("score")
|
||||
mid = mem.get("id", "")
|
||||
score_str = f" (score: {score:.2f})" if score else ""
|
||||
lines.append(f"{i}. [{mid}] {text}{score_str}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MemoryListTool(Tool):
|
||||
"""List all memories for the current user."""
|
||||
|
||||
name = "memory_list"
|
||||
description = (
|
||||
"List ALL stored memories for the current user. "
|
||||
"Use memory_search for targeted lookup; use this to browse everything."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, **kw: Any) -> str:
|
||||
memories = self._ctx.store.get_all_memories(self._ctx.user_id)
|
||||
if not memories:
|
||||
return "No memories stored."
|
||||
lines = []
|
||||
for i, mem in enumerate(memories, 1):
|
||||
text = mem.get("memory", "")
|
||||
mid = mem.get("id", "")
|
||||
lines.append(f"{i}. [{mid}] {text}")
|
||||
return f"{len(memories)} memories:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
class MemoryAddTool(Tool):
|
||||
"""Add a fact to long-term memory."""
|
||||
|
||||
name = "memory_add"
|
||||
description = (
|
||||
"Store a new fact or piece of information in long-term memory. "
|
||||
"The content will be processed by the extraction LLM and stored as one or more facts."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The fact or information to remember",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, content: str, **kw: Any) -> str:
|
||||
try:
|
||||
result = self._ctx.store.memory.add(
|
||||
[{"role": "user", "content": content}],
|
||||
user_id=self._ctx.user_id,
|
||||
)
|
||||
facts_count = len(result.get("results", [])) if result else 0
|
||||
return f"Added to memory. {facts_count} fact(s) extracted."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_add failed: {e}")
|
||||
return f"Error adding memory: {e}"
|
||||
|
||||
|
||||
class MemoryUpdateTool(Tool):
|
||||
"""Update an existing memory by ID."""
|
||||
|
||||
name = "memory_update"
|
||||
description = (
|
||||
"Update the content of an existing memory. "
|
||||
"Use memory_list or memory_search first to find the memory ID."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory ID to update",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The new content for this memory",
|
||||
},
|
||||
},
|
||||
"required": ["memory_id", "content"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, memory_id: str, content: str, **kw: Any) -> str:
|
||||
try:
|
||||
self._ctx.store.update_memory(memory_id, content)
|
||||
return f"Memory {memory_id} updated."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_update failed: {e}")
|
||||
return f"Error updating memory: {e}"
|
||||
|
||||
|
||||
class MemoryDeleteTool(Tool):
|
||||
"""Delete a memory by ID."""
|
||||
|
||||
name = "memory_delete"
|
||||
description = (
|
||||
"Delete a specific memory by its ID. "
|
||||
"Use memory_list or memory_search first to find the memory ID."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory ID to delete",
|
||||
},
|
||||
},
|
||||
"required": ["memory_id"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, memory_id: str, **kw: Any) -> str:
|
||||
try:
|
||||
self._ctx.store.delete_memory(memory_id)
|
||||
return f"Memory {memory_id} deleted."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_delete failed: {e}")
|
||||
return f"Error deleting memory: {e}"
|
||||
|
||||
|
||||
class MemoryConsolidateTool(Tool):
|
||||
"""Trigger memory consolidation for the current session."""
|
||||
|
||||
name = "memory_consolidate"
|
||||
description = (
|
||||
"Extract and store facts from the current conversation into long-term memory. "
|
||||
"Normally this happens automatically on /new, but you can trigger it manually."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, **kw: Any) -> str:
|
||||
session = self._ctx.session
|
||||
if not session:
|
||||
return "Error: no active session."
|
||||
try:
|
||||
await self._ctx.consolidate_fn(session, archive_all=False)
|
||||
return "Memory consolidation complete."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_consolidate failed: {e}")
|
||||
return f"Error during consolidation: {e}"
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Async message queue for decoupled channel-agent communication."""
|
||||
|
||||
import asyncio
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
from loguru import logger
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+264
-374
@@ -2,25 +2,26 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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 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
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.cli.oauth import oauth_app
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
@@ -157,7 +158,7 @@ def main(
|
||||
@app.command()
|
||||
def onboard():
|
||||
"""Initialize nanobot configuration and workspace."""
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.loader import get_config_path, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
|
||||
@@ -165,28 +166,20 @@ def onboard():
|
||||
|
||||
if config_path.exists():
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
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}")
|
||||
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}")
|
||||
|
||||
# Create workspace
|
||||
workspace = get_workspace_path()
|
||||
console.print(f"[green]✓[/green] Created workspace at {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)
|
||||
# Create default bootstrap files
|
||||
_create_workspace_templates(workspace)
|
||||
|
||||
console.print(f"\n{__logo__} nanobot is ready!")
|
||||
console.print("\nNext steps:")
|
||||
@@ -198,20 +191,98 @@ def onboard():
|
||||
|
||||
|
||||
|
||||
def _create_workspace_templates(workspace: Path):
|
||||
"""Create default workspace template files."""
|
||||
templates = {
|
||||
"AGENTS.md": """# Agent Instructions
|
||||
|
||||
def _make_provider(config: Config):
|
||||
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):
|
||||
"""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 "",
|
||||
api_key=p.api_key if p else None,
|
||||
model=model,
|
||||
api_base=config.get_api_base(),
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
@@ -225,20 +296,40 @@ def _make_provider(config: 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.config.loader import load_config, get_data_dir
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
@@ -247,7 +338,6 @@ 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)
|
||||
@@ -256,14 +346,37 @@ 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,
|
||||
@@ -271,9 +384,8 @@ 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)
|
||||
@@ -295,59 +407,50 @@ 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)
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||
enabled = set(channels.enabled_channels)
|
||||
# Prefer the most recently updated non-internal session on an enabled channel.
|
||||
for item in session_manager.list_sessions():
|
||||
key = item.get("key") or ""
|
||||
if ":" not in key:
|
||||
continue
|
||||
channel, chat_id = key.split(":", 1)
|
||||
if channel in {"cli", "system"}:
|
||||
continue
|
||||
if channel in enabled and chat_id:
|
||||
return channel, chat_id
|
||||
# Fallback keeps prior behavior but remains explicit.
|
||||
return "cli", "direct"
|
||||
# Create hooks server
|
||||
from nanobot.channels.hook import HookChannel
|
||||
from nanobot.hooks.server import HooksServer
|
||||
|
||||
# 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()
|
||||
hooks_config = config.hooks if hasattr(config, 'hooks') else None
|
||||
hooks_server = None
|
||||
|
||||
async def _silent(*_args, **_kwargs):
|
||||
pass
|
||||
if hooks_config and hooks_config.enabled:
|
||||
# Register hook channel
|
||||
hook_channel = HookChannel(bus)
|
||||
channels.register_channel("hook", hook_channel)
|
||||
|
||||
return await agent.process_direct(
|
||||
tasks,
|
||||
session_key="heartbeat",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
on_progress=_silent,
|
||||
# Create hooks server (checks has_tokens internally)
|
||||
hooks_server = HooksServer(
|
||||
host=config.gateway.host,
|
||||
port=config.gateway.port,
|
||||
config=hooks_config,
|
||||
bus=bus,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
@@ -358,20 +461,24 @@ def gateway(
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||
console.print("[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
_start_moltbook_loop()
|
||||
|
||||
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...")
|
||||
finally:
|
||||
await agent.close_mcp()
|
||||
if hooks_server:
|
||||
await hooks_server.stop()
|
||||
heartbeat.stop()
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
@@ -395,43 +502,56 @@ 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,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
mem0_config=mem0_config,
|
||||
)
|
||||
|
||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||
@@ -442,34 +562,19 @@ 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 — direct call, no bus needed
|
||||
# Single message mode
|
||||
async def run_once():
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
|
||||
response = await agent_loop.process_direct(message, session_id)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
await agent_loop.close_mcp()
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
# Interactive mode — route through bus like other channels
|
||||
from nanobot.bus.events import InboundMessage
|
||||
# Interactive mode
|
||||
_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!")
|
||||
@@ -478,80 +583,30 @@ def agent(
|
||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||
|
||||
async def run_interactive():
|
||||
bus_task = asyncio.create_task(agent_loop.run())
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
turn_response: list[str] = []
|
||||
|
||||
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:
|
||||
while True:
|
||||
try:
|
||||
_flush_pending_tty_input()
|
||||
user_input = await _read_interactive_input_async()
|
||||
command = user_input.strip()
|
||||
if not command:
|
||||
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:
|
||||
if _is_exit_command(command):
|
||||
_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()
|
||||
|
||||
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
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
@@ -564,6 +619,8 @@ 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():
|
||||
@@ -628,33 +685,6 @@ 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)
|
||||
|
||||
|
||||
@@ -720,6 +750,7 @@ 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()
|
||||
@@ -773,26 +804,20 @@ 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 = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
||||
sched = job.schedule.expr or ""
|
||||
else:
|
||||
sched = "one-time"
|
||||
|
||||
# Format next run
|
||||
next_run = ""
|
||||
if job.state.next_run_at_ms:
|
||||
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))
|
||||
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
||||
next_run = next_time
|
||||
|
||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||
|
||||
@@ -807,7 +832,6 @@ 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"),
|
||||
@@ -818,15 +842,11 @@ 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, tz=tz)
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
||||
elif at:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromisoformat(at)
|
||||
@@ -838,18 +858,14 @@ def cron_add(
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
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
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||
|
||||
@@ -897,58 +913,17 @@ def cron_run(
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
|
||||
):
|
||||
"""Manually run a job."""
|
||||
from loguru import logger
|
||||
from nanobot.config.loader import load_config, get_data_dir
|
||||
from nanobot.config.loader import 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]")
|
||||
|
||||
@@ -961,7 +936,7 @@ def cron_run(
|
||||
@app.command()
|
||||
def status():
|
||||
"""Show nanobot status."""
|
||||
from nanobot.config.loader import load_config, get_config_path
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
|
||||
config_path = get_config_path()
|
||||
config = load_config()
|
||||
@@ -982,9 +957,7 @@ def status():
|
||||
p = getattr(config.providers, spec.name, None)
|
||||
if p is None:
|
||||
continue
|
||||
if spec.is_oauth:
|
||||
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
|
||||
elif spec.is_local:
|
||||
if 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]")
|
||||
@@ -995,88 +968,5 @@ 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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Any, 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(Base):
|
||||
class HooksConfig(BaseModel):
|
||||
"""Webhook endpoint configuration."""
|
||||
enabled: bool = False
|
||||
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
|
||||
@@ -330,7 +330,7 @@ class HooksConfig(Base):
|
||||
return bool(self.tokens)
|
||||
|
||||
|
||||
class WebSearchConfig(Base):
|
||||
class WebSearchConfig(BaseModel):
|
||||
"""Web search tool configuration."""
|
||||
|
||||
api_key: str = "" # Brave Search API key
|
||||
@@ -361,6 +361,17 @@ 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."""
|
||||
|
||||
@@ -368,6 +379,7 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -59,9 +59,83 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create async HTTP client."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=300.0)
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(300.0, pool=30.0),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def _reset_client(self) -> None:
|
||||
"""Destroy and recreate the HTTP client after connection errors."""
|
||||
old = self._client
|
||||
self._client = None
|
||||
if old:
|
||||
try:
|
||||
await old.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Reset httpx client (pool recycled)")
|
||||
|
||||
async def _diagnose_connectivity(self) -> None:
|
||||
"""Run diagnostics when ConnectTimeout occurs to understand why."""
|
||||
import socket
|
||||
import asyncio
|
||||
|
||||
# 1. Raw socket test (bypasses httpx entirely)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
s = socket.create_connection(('api.anthropic.com', 443), timeout=10)
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
s.close()
|
||||
logger.warning(f"DIAG: raw socket connect OK in {elapsed:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: raw socket connect FAILED: {e}")
|
||||
|
||||
# 2. asyncio connect test (same event loop)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection('api.anthropic.com', 443),
|
||||
timeout=10.0,
|
||||
)
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
logger.warning(f"DIAG: asyncio connect OK in {elapsed:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: asyncio connect FAILED: {e}")
|
||||
|
||||
# 3. Fresh httpx client test (new pool)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
async with httpx.AsyncClient(timeout=10.0) as fresh:
|
||||
r = await fresh.get('https://api.anthropic.com/')
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
logger.warning(f"DIAG: fresh httpx OK in {elapsed:.3f}s (status={r.status_code})")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: fresh httpx FAILED: {e}")
|
||||
|
||||
# 4. DNS resolution
|
||||
try:
|
||||
ips = socket.getaddrinfo('api.anthropic.com', 443)
|
||||
logger.warning(f"DIAG: DNS resolved to {len(ips)} entries, first={ips[0][4][0]}")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: DNS FAILED: {e}")
|
||||
|
||||
# 5. Connection pool state of the broken client
|
||||
if self._client:
|
||||
transport = self._client._transport
|
||||
if hasattr(transport, '_pool'):
|
||||
pool = transport._pool
|
||||
conns = getattr(pool, '_connections', [])
|
||||
reqs = getattr(pool, '_requests', [])
|
||||
logger.warning(
|
||||
f"DIAG: pool state: {len(conns)} connections, "
|
||||
f"{len(reqs)} pending requests"
|
||||
)
|
||||
for i, conn in enumerate(conns[:5]):
|
||||
state = getattr(conn, '_state', 'unknown')
|
||||
logger.warning(f"DIAG: conn[{i}] state={state}")
|
||||
|
||||
def _prepare_messages(
|
||||
self,
|
||||
messages: list[dict[str, Any]]
|
||||
@@ -252,18 +326,35 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
"""Make request to Anthropic API."""
|
||||
client = await self._get_client()
|
||||
|
||||
# Cache the last user message so conversation history is cached across turns
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
if last.get("role") == "user":
|
||||
content = last["content"]
|
||||
if isinstance(content, str):
|
||||
last = {**last, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
last = {**last, "content": new_content}
|
||||
messages = messages[:-1] + [last]
|
||||
# Add cache breakpoints on the last TWO user messages (4-breakpoint strategy):
|
||||
# BP3: Second-to-last user message (stable history from previous turn)
|
||||
# BP4: Last user message (current turn, will become BP3 next turn)
|
||||
# This allows BP3 to reuse what BP4 cached last turn.
|
||||
user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
|
||||
|
||||
if len(user_indices) >= 2:
|
||||
# BP3: Second-to-last user message
|
||||
idx = user_indices[-2]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
if len(user_indices) >= 1:
|
||||
# BP4: Last user message
|
||||
idx = user_indices[-1]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -316,11 +407,48 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
headers.get("anthropic-beta", "none"),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
# 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}")
|
||||
|
||||
# Debug: Log message structure to diagnose orphaned tool_result errors
|
||||
for idx, m in enumerate(payload.get("messages", [])):
|
||||
role = m.get("role", "?")
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
block_types = [b.get("type", "?") for b in content]
|
||||
logger.debug(f" msg[{idx}] role={role} blocks={block_types}")
|
||||
else:
|
||||
logger.debug(f" msg[{idx}] role={role} text={str(content)[:80]}")
|
||||
|
||||
import asyncio
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
except httpx.ConnectTimeout:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"ConnectTimeout after {elapsed:.1f}s — running diagnostics")
|
||||
await self._diagnose_connectivity()
|
||||
await self._reset_client()
|
||||
raise
|
||||
except httpx.PoolTimeout:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"PoolTimeout after {elapsed:.1f}s — resetting client")
|
||||
await self._reset_client()
|
||||
raise
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"{type(e).__name__} after {elapsed:.1f}s")
|
||||
raise
|
||||
elapsed = _time.monotonic() - _t0
|
||||
if elapsed > 30:
|
||||
logger.warning(f"Anthropic API slow response: {elapsed:.1f}s")
|
||||
|
||||
# Dump rate limit headers for analysis
|
||||
try:
|
||||
@@ -412,8 +540,10 @@ 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: {str(e)}",
|
||||
content=f"Error calling LLM: {error_msg}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
|
||||
+40
-34
@@ -1,6 +1,7 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -17,6 +18,10 @@ 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
|
||||
@@ -24,6 +29,7 @@ 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."""
|
||||
@@ -65,8 +71,9 @@ class Session:
|
||||
]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages in the session."""
|
||||
"""Clear all messages and reset session to initial state."""
|
||||
self.messages = []
|
||||
self.last_consolidated = 0
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
|
||||
@@ -79,7 +86,8 @@ class SessionManager:
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
|
||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
||||
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
|
||||
self._cache: dict[str, Session] = {}
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
@@ -87,6 +95,11 @@ 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.
|
||||
@@ -97,11 +110,9 @@ 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)
|
||||
@@ -112,6 +123,14 @@ 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
|
||||
@@ -120,8 +139,9 @@ class SessionManager:
|
||||
messages = []
|
||||
metadata = {}
|
||||
created_at = None
|
||||
last_consolidated = 0
|
||||
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -132,6 +152,7 @@ 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)
|
||||
|
||||
@@ -139,52 +160,36 @@ class SessionManager:
|
||||
key=key,
|
||||
messages=messages,
|
||||
created_at=created_at or datetime.now(),
|
||||
metadata=metadata
|
||||
metadata=metadata,
|
||||
last_consolidated=last_consolidated
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load session {key}: {e}")
|
||||
logger.warning("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") as f:
|
||||
# Write metadata first
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
metadata_line = {
|
||||
"_type": "metadata",
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"metadata": session.metadata
|
||||
"metadata": session.metadata,
|
||||
"last_consolidated": session.last_consolidated
|
||||
}
|
||||
f.write(json.dumps(metadata_line) + "\n")
|
||||
|
||||
# Write messages
|
||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||
for msg in session.messages:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||
|
||||
self._cache[session.key] = session
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
key: Session key.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found.
|
||||
"""
|
||||
# Remove from cache
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove a session from the in-memory 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.
|
||||
@@ -197,13 +202,14 @@ class SessionManager:
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
try:
|
||||
# Read just the metadata line
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") 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": path.stem.replace("_", ":"),
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"path": str(path)
|
||||
|
||||
@@ -47,6 +47,9 @@ dev = [
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
mem0 = [
|
||||
"mem0ai>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nanobot = "nanobot.cli.commands:app"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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
|
||||
@@ -40,7 +40,7 @@ async def test_bash_tool_restart():
|
||||
|
||||
# Restart
|
||||
result = await tool(restart=True)
|
||||
assert "restarted" in result.output.lower()
|
||||
assert "restarted" in (result.system or result.output or "").lower()
|
||||
|
||||
# Variable should be gone
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
|
||||
Reference in New Issue
Block a user