Merge remote-tracking branch 'origin/main' into pr-1191

This commit is contained in:
Re-bin
2026-02-26 02:39:53 +00:00
11 changed files with 320 additions and 194 deletions

View File

@@ -138,12 +138,13 @@ Add or merge these **two parts** into your config (other options have defaults).
} }
``` ```
*Set your model*: *Set your model* (optionally pin a provider — defaults to auto-detection):
```json ```json
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5",
"provider": "openrouter"
} }
} }
} }
@@ -807,6 +808,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |

View File

@@ -13,14 +13,10 @@ from nanobot.agent.skills import SkillsLoader
class ContextBuilder: class ContextBuilder:
""" """Builds the context (system prompt + messages) for the agent."""
Builds the context (system prompt + messages) for the agent.
Assembles bootstrap files, memory, skills, and conversation history
into a coherent prompt for the LLM.
"""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
def __init__(self, workspace: Path): def __init__(self, workspace: Path):
self.workspace = workspace self.workspace = workspace
@@ -28,39 +24,23 @@ class ContextBuilder:
self.skills = SkillsLoader(workspace) self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str: def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
""" """Build the system prompt from identity, bootstrap files, memory, and skills."""
Build the system prompt from bootstrap files, memory, and skills. parts = [self._get_identity()]
Args:
skill_names: Optional list of skills to include.
Returns:
Complete system prompt.
"""
parts = []
# Core identity
parts.append(self._get_identity())
# Bootstrap files
bootstrap = self._load_bootstrap_files() bootstrap = self._load_bootstrap_files()
if bootstrap: if bootstrap:
parts.append(bootstrap) parts.append(bootstrap)
# Memory context
memory = self.memory.get_memory_context() memory = self.memory.get_memory_context()
if memory: if memory:
parts.append(f"# Memory\n\n{memory}") parts.append(f"# Memory\n\n{memory}")
# Skills - progressive loading
# 1. Always-loaded skills: include full content
always_skills = self.skills.get_always_skills() always_skills = self.skills.get_always_skills()
if always_skills: if always_skills:
always_content = self.skills.load_skills_for_context(always_skills) always_content = self.skills.load_skills_for_context(always_skills)
if always_content: if always_content:
parts.append(f"# Active Skills\n\n{always_content}") parts.append(f"# Active Skills\n\n{always_content}")
# 2. Available skills: only show summary (agent uses read_file to load)
skills_summary = self.skills.build_skills_summary() skills_summary = self.skills.build_skills_summary()
if skills_summary: if skills_summary:
parts.append(f"""# Skills parts.append(f"""# Skills
@@ -69,7 +49,7 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""") {skills_summary}""")
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str: def _get_identity(self) -> str:
@@ -80,46 +60,35 @@ Skills with available="false" need dependencies installed first - you can try in
return f"""# nanobot 🐈 return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You are nanobot, a helpful AI assistant.
## Runtime ## Runtime
{runtime} {runtime}
## Workspace ## Workspace
Your workspace is at: {workspace_path} Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md - Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. ## nanobot Guidelines
- State intent before tool calls, but NEVER predict or claim results before receiving them.
## Tool Call Guidelines - Before modifying a file, read it first. Do not assume files or directories exist.
- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it.
- Before modifying a file, read it first to confirm its current content.
- Do not assume a file or directory exists — use list_dir or read_file to verify.
- After writing or editing a file, re-read it if accuracy matters. - After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, analyze the error before retrying with a different approach. - If a tool call fails, analyze the error before retrying with a different approach.
- Ask for clarification when the request is ambiguous.
## Memory Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel."""
- Remember important facts: write to {workspace_path}/memory/MEMORY.md
- Recall past events: grep {workspace_path}/memory/HISTORY.md"""
@staticmethod @staticmethod
def _inject_runtime_context( def _build_runtime_context(channel: str | None, chat_id: str | None) -> str:
user_content: str | list[dict[str, Any]], """Build untrusted runtime metadata block for injection before the user message."""
channel: str | None,
chat_id: str | None,
) -> str | list[dict[str, Any]]:
"""Append dynamic runtime context to the tail of the user message."""
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = time.strftime("%Z") or "UTC" tz = time.strftime("%Z") or "UTC"
lines = [f"Current Time: {now} ({tz})"] lines = [f"Current Time: {now} ({tz})"]
if channel and chat_id: if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
block = "[Runtime Context]\n" + "\n".join(lines) return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
if isinstance(user_content, str):
return f"{user_content}\n\n{block}"
return [*user_content, {"type": "text", "text": block}]
def _load_bootstrap_files(self) -> str: def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace.""" """Load all bootstrap files from workspace."""
@@ -142,35 +111,13 @@ Reply directly with text for conversations. Only use the 'message' tool to send
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None, chat_id: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """Build the complete message list for an LLM call."""
Build the complete message list for an LLM call. return [
{"role": "system", "content": self.build_system_prompt(skill_names)},
Args: *history,
history: Previous conversation messages. {"role": "user", "content": self._build_runtime_context(channel, chat_id)},
current_message: The new user message. {"role": "user", "content": self._build_user_content(current_message, media)},
skill_names: Optional skills to include. ]
media: Optional list of local file paths for images/media.
channel: Current channel (telegram, feishu, etc.).
chat_id: Current chat/user ID.
Returns:
List of messages including system prompt.
"""
messages = []
# System prompt
system_prompt = self.build_system_prompt(skill_names)
messages.append({"role": "system", "content": system_prompt})
# History
messages.extend(history)
# Current message (with optional image attachments)
user_content = self._build_user_content(current_message, media)
user_content = self._inject_runtime_context(user_content, channel, chat_id)
messages.append({"role": "user", "content": user_content})
return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images.""" """Build user message content with optional base64-encoded images."""
@@ -191,63 +138,24 @@ Reply directly with text for conversations. Only use the 'message' tool to send
return images + [{"type": "text", "text": text}] return images + [{"type": "text", "text": text}]
def add_tool_result( def add_tool_result(
self, self, messages: list[dict[str, Any]],
messages: list[dict[str, Any]], tool_call_id: str, tool_name: str, result: str,
tool_call_id: str,
tool_name: str,
result: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """Add a tool result to the message list."""
Add a tool result to the message list. messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result})
Args:
messages: Current message list.
tool_call_id: ID of the tool call.
tool_name: Name of the tool.
result: Tool execution result.
Returns:
Updated message list.
"""
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result
})
return messages return messages
def add_assistant_message( def add_assistant_message(
self, self, messages: list[dict[str, Any]],
messages: list[dict[str, Any]],
content: str | None, content: str | None,
tool_calls: list[dict[str, Any]] | None = None, tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None, reasoning_content: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """Add an assistant message to the message list."""
Add an assistant message to the message list. msg: dict[str, Any] = {"role": "assistant", "content": content}
Args:
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
Returns:
Updated message list.
"""
msg: dict[str, Any] = {"role": "assistant"}
# Always include content — some providers (e.g. StepFun) reject
# assistant messages that omit the key entirely.
msg["content"] = content
if tool_calls: if tool_calls:
msg["tool_calls"] = tool_calls msg["tool_calls"] = tool_calls
# Include reasoning content when provided (required by some thinking models)
if reasoning_content is not None: if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content msg["reasoning_content"] = reasoning_content
messages.append(msg) messages.append(msg)
return messages return messages

View File

@@ -99,6 +99,8 @@ class AgentLoop:
self._consolidating: set[str] = set() # Session keys with consolidation in progress self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
self._consolidation_locks: dict[str, asyncio.Lock] = {} self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._processing_lock = asyncio.Lock()
self._register_default_tools() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@@ -110,6 +112,7 @@ class AgentLoop:
working_dir=str(self.workspace), working_dir=str(self.workspace),
timeout=self.exec_config.timeout, timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace, restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
)) ))
self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool()) self.tools.register(WebFetchTool())
@@ -225,7 +228,13 @@ class AgentLoop:
messages, tool_call.id, tool_call.name, result messages, tool_call.id, tool_call.name, result
) )
else: else:
final_content = self._strip_think(response.content) clean = self._strip_think(response.content)
if on_progress and clean:
await on_progress(clean)
messages = self.context.add_assistant_message(
messages, clean, reasoning_content=response.reasoning_content,
)
final_content = clean
break break
if final_content is None and iteration >= self.max_iterations: if final_content is None and iteration >= self.max_iterations:
@@ -238,35 +247,62 @@ class AgentLoop:
return final_content, tools_used, messages return final_content, tools_used, messages
async def run(self) -> None: async def run(self) -> None:
"""Run the agent loop, processing messages from the bus.""" """Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True self._running = True
await self._connect_mcp() await self._connect_mcp()
logger.info("Agent loop started") logger.info("Agent loop started")
while self._running: while self._running:
try: try:
msg = await asyncio.wait_for( msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
self.bus.consume_inbound(),
timeout=1.0
)
try:
response = await self._process_message(msg)
if response is not None:
await self.bus.publish_outbound(response)
elif msg.channel == "cli":
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {},
))
except Exception as e:
logger.error("Error processing message: {}", e)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}"
))
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
if msg.content.strip().lower() == "/stop":
await self._handle_stop(msg)
else:
task = asyncio.create_task(self._dispatch(msg))
self._active_tasks.setdefault(msg.session_key, []).append(task)
task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
async def _handle_stop(self, msg: InboundMessage) -> None:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop."
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message under the global lock."""
async with self._processing_lock:
try:
response = await self._process_message(msg)
if response is not None:
await self.bus.publish_outbound(response)
elif msg.channel == "cli":
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", msg.session_key)
raise
except Exception:
logger.exception("Error processing message for session {}", msg.session_key)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Sorry, I encountered an error.",
))
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
"""Close MCP connections.""" """Close MCP connections."""
if self._mcp_stack: if self._mcp_stack:
@@ -358,7 +394,7 @@ class AgentLoop:
content="New session started.") content="New session started.")
if cmd == "/help": if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
unconsolidated = len(session.messages) - session.last_consolidated unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating): if (unconsolidated >= self.memory_window and session.key not in self._consolidating):

View File

@@ -18,13 +18,7 @@ from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
class SubagentManager: class SubagentManager:
""" """Manages background subagent execution."""
Manages background subagent execution.
Subagents are lightweight agent instances that run in the background
to handle specific tasks. They share the same LLM provider but have
isolated context and a focused system prompt.
"""
def __init__( def __init__(
self, self,
@@ -49,6 +43,7 @@ class SubagentManager:
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
async def spawn( async def spawn(
self, self,
@@ -56,35 +51,28 @@ class SubagentManager:
label: str | None = None, label: str | None = None,
origin_channel: str = "cli", origin_channel: str = "cli",
origin_chat_id: str = "direct", origin_chat_id: str = "direct",
session_key: str | None = None,
) -> str: ) -> str:
""" """Spawn a subagent to execute a task in the background."""
Spawn a subagent to execute a task in the background.
Args:
task: The task description for the subagent.
label: Optional human-readable label for the task.
origin_channel: The channel to announce results to.
origin_chat_id: The chat ID to announce results to.
Returns:
Status message indicating the subagent was started.
"""
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "") display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id}
origin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
}
# Create background task
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin) self._run_subagent(task_id, task, display_label, origin)
) )
self._running_tasks[task_id] = bg_task self._running_tasks[task_id] = bg_task
if session_key:
# Cleanup when done self._session_tasks.setdefault(session_key, set()).add(task_id)
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
def _cleanup(_: asyncio.Task) -> None:
self._running_tasks.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)):
ids.discard(task_id)
if not ids:
del self._session_tasks[session_key]
bg_task.add_done_callback(_cleanup)
logger.info("Spawned subagent [{}]: {}", task_id, display_label) logger.info("Spawned subagent [{}]: {}", task_id, display_label)
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
@@ -111,6 +99,7 @@ class SubagentManager:
working_dir=str(self.workspace), working_dir=str(self.workspace),
timeout=self.exec_config.timeout, timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace, restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
)) ))
tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool()) tools.register(WebFetchTool())
@@ -252,6 +241,16 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
When you have completed the task, provide a clear summary of your findings or actions.""" When you have completed the task, provide a clear summary of your findings or actions."""
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
if tid in self._running_tasks and not self._running_tasks[tid].done()]
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
return len(tasks)
def get_running_count(self) -> int: def get_running_count(self) -> int:
"""Return the number of currently running subagents.""" """Return the number of currently running subagents."""
return len(self._running_tasks) return len(self._running_tasks)

View File

@@ -19,6 +19,7 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None, deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None, allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
path_append: str = "",
): ):
self.timeout = timeout self.timeout = timeout
self.working_dir = working_dir self.working_dir = working_dir
@@ -35,6 +36,7 @@ class ExecTool(Tool):
] ]
self.allow_patterns = allow_patterns or [] self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
@property @property
def name(self) -> str: def name(self) -> str:
@@ -67,12 +69,17 @@ class ExecTool(Tool):
if guard_error: if guard_error:
return guard_error return guard_error
env = os.environ.copy()
if self.path_append:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
try: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env,
) )
try: try:

View File

@@ -15,11 +15,13 @@ class SpawnTool(Tool):
self._manager = manager self._manager = manager
self._origin_channel = "cli" self._origin_channel = "cli"
self._origin_chat_id = "direct" self._origin_chat_id = "direct"
self._session_key = "cli:direct"
def set_context(self, channel: str, chat_id: str) -> None: def set_context(self, channel: str, chat_id: str) -> None:
"""Set the origin context for subagent announcements.""" """Set the origin context for subagent announcements."""
self._origin_channel = channel self._origin_channel = channel
self._origin_chat_id = chat_id self._origin_chat_id = chat_id
self._session_key = f"{channel}:{chat_id}"
@property @property
def name(self) -> str: def name(self) -> str:
@@ -57,4 +59,5 @@ class SpawnTool(Tool):
label=label, label=label,
origin_channel=self._origin_channel, origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id, origin_chat_id=self._origin_chat_id,
session_key=self._session_key,
) )

View File

@@ -111,6 +111,7 @@ class TelegramChannel(BaseChannel):
BOT_COMMANDS = [ BOT_COMMANDS = [
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"), BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"), BotCommand("help", "Show available commands"),
] ]
@@ -299,6 +300,7 @@ class TelegramChannel(BaseChannel):
await update.message.reply_text( await update.message.reply_text(
"🐈 nanobot commands:\n" "🐈 nanobot commands:\n"
"/new — Start a new conversation\n" "/new — Start a new conversation\n"
"/stop — Stop the current task\n"
"/help — Show available commands" "/help — Show available commands"
) )

View File

@@ -186,6 +186,7 @@ class AgentDefaults(Base):
workspace: str = "~/.nanobot/workspace" workspace: str = "~/.nanobot/workspace"
model: str = "anthropic/claude-opus-4-5" model: str = "anthropic/claude-opus-4-5"
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
max_tokens: int = 8192 max_tokens: int = 8192
temperature: float = 0.1 temperature: float = 0.1
max_tool_iterations: int = 40 max_tool_iterations: int = 40
@@ -260,6 +261,7 @@ class ExecToolConfig(Base):
"""Shell exec tool configuration.""" """Shell exec tool configuration."""
timeout: int = 60 timeout: int = 60
path_append: str = ""
class MCPServerConfig(Base): class MCPServerConfig(Base):
@@ -300,6 +302,11 @@ class Config(BaseSettings):
"""Match provider config and its registry name. Returns (config, spec_name).""" """Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS from nanobot.providers.registry import PROVIDERS
forced = self.agents.defaults.provider
if forced != "auto":
p = getattr(self.providers, forced, None)
return (p, forced) if p else (None, None)
model_lower = (model or self.agents.defaults.model).lower() model_lower = (model or self.agents.defaults.model).lower()
model_normalized = model_lower.replace("-", "_") model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""

View File

@@ -2,14 +2,6 @@
You are a helpful AI assistant. Be concise, accurate, and friendly. You are a helpful AI assistant. Be concise, accurate, and friendly.
## Guidelines
- Before calling tools, briefly state your intent — but NEVER predict results before receiving them
- Use precise tense: "I will run X" before the call, "X returned Y" after
- NEVER claim success before a tool result confirms it
- Ask for clarification when the request is ambiguous
- Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md`
## Scheduled Reminders ## Scheduled Reminders
When user asks for a reminder at a specific time, use `exec` to run: When user asks for a reminder at a specific time, use `exec` to run:

View File

@@ -39,8 +39,8 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) ->
assert prompt1 == prompt2 assert prompt1 == prompt2
def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
"""Dynamic runtime details should be added at the tail user message, not system.""" """Runtime metadata should be a separate user message before the actual user message."""
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace) builder = ContextBuilder(workspace)
@@ -54,10 +54,13 @@ def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None:
assert messages[0]["role"] == "system" assert messages[0]["role"] == "system"
assert "## Current Session" not in messages[0]["content"] assert "## Current Session" not in messages[0]["content"]
assert messages[-2]["role"] == "user"
runtime_content = messages[-2]["content"]
assert isinstance(runtime_content, str)
assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content
assert "Current Time:" in runtime_content
assert "Channel: cli" in runtime_content
assert "Chat ID: direct" in runtime_content
assert messages[-1]["role"] == "user" assert messages[-1]["role"] == "user"
user_content = messages[-1]["content"] assert messages[-1]["content"] == "Return exactly: OK"
assert isinstance(user_content, str)
assert "Return exactly: OK" in user_content
assert "Current Time:" in user_content
assert "Channel: cli" in user_content
assert "Chat ID: direct" in user_content

167
tests/test_task_cancel.py Normal file
View File

@@ -0,0 +1,167 @@
"""Tests for /stop task cancellation."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_loop():
"""Create a minimal AgentLoop with mocked dependencies."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
workspace = MagicMock()
workspace.__truediv__ = MagicMock(return_value=MagicMock())
with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
return loop, bus
class TestHandleStop:
@pytest.mark.asyncio
async def test_stop_no_active_task(self):
from nanobot.bus.events import InboundMessage
loop, bus = _make_loop()
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "No active task" in out.content
@pytest.mark.asyncio
async def test_stop_cancels_active_task(self):
from nanobot.bus.events import InboundMessage
loop, bus = _make_loop()
cancelled = asyncio.Event()
async def slow_task():
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
cancelled.set()
raise
task = asyncio.create_task(slow_task())
await asyncio.sleep(0)
loop._active_tasks["test:c1"] = [task]
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg)
assert cancelled.is_set()
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "stopped" in out.content.lower()
@pytest.mark.asyncio
async def test_stop_cancels_multiple_tasks(self):
from nanobot.bus.events import InboundMessage
loop, bus = _make_loop()
events = [asyncio.Event(), asyncio.Event()]
async def slow(idx):
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
events[idx].set()
raise
tasks = [asyncio.create_task(slow(i)) for i in range(2)]
await asyncio.sleep(0)
loop._active_tasks["test:c1"] = tasks
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg)
assert all(e.is_set() for e in events)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "2 task" in out.content
class TestDispatch:
@pytest.mark.asyncio
async def test_dispatch_processes_and_publishes(self):
from nanobot.bus.events import InboundMessage, OutboundMessage
loop, bus = _make_loop()
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="hello")
loop._process_message = AsyncMock(
return_value=OutboundMessage(channel="test", chat_id="c1", content="hi")
)
await loop._dispatch(msg)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert out.content == "hi"
@pytest.mark.asyncio
async def test_processing_lock_serializes(self):
from nanobot.bus.events import InboundMessage, OutboundMessage
loop, bus = _make_loop()
order = []
async def mock_process(m, **kwargs):
order.append(f"start-{m.content}")
await asyncio.sleep(0.05)
order.append(f"end-{m.content}")
return OutboundMessage(channel="test", chat_id="c1", content=m.content)
loop._process_message = mock_process
msg1 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="a")
msg2 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="b")
t1 = asyncio.create_task(loop._dispatch(msg1))
t2 = asyncio.create_task(loop._dispatch(msg2))
await asyncio.gather(t1, t2)
assert order == ["start-a", "end-a", "start-b", "end-b"]
class TestSubagentCancellation:
@pytest.mark.asyncio
async def test_cancel_by_session(self):
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
cancelled = asyncio.Event()
async def slow():
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
cancelled.set()
raise
task = asyncio.create_task(slow())
await asyncio.sleep(0)
mgr._running_tasks["sub-1"] = task
mgr._session_tasks["test:c1"] = {"sub-1"}
count = await mgr.cancel_by_session("test:c1")
assert count == 1
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_cancel_by_session_no_tasks(self):
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
assert await mgr.cancel_by_session("nonexistent") == 0