2026-02-01 07:36:42 +00:00
"""Agent loop: the core processing engine."""
import asyncio
2026-02-12 10:01:30 +01:00
from contextlib import AsyncExitStack
2026-02-01 07:36:42 +00:00
import json
2026-02-15 08:11:33 +00:00
import json_repair
2026-02-01 07:36:42 +00:00
from pathlib import Path
2026-02-18 14:23:51 +00:00
import re
from typing import Any , Awaitable , Callable
2026-02-01 07:36:42 +00:00
from loguru import logger
from nanobot.bus.events import InboundMessage , OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.context import ContextBuilder
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool , WriteFileTool , EditFileTool , ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool , WebFetchTool
from nanobot.agent.tools.message import MessageTool
2026-02-01 16:28:45 +00:00
from nanobot.agent.tools.spawn import SpawnTool
2026-02-05 15:09:51 +00:00
from nanobot.agent.tools.cron import CronTool
2026-02-12 15:02:52 +00:00
from nanobot.agent.memory import MemoryStore
2026-02-01 16:28:45 +00:00
from nanobot.agent.subagent import SubagentManager
2026-02-13 15:14:22 +08:00
from nanobot.session.manager import Session , SessionManager
2026-02-01 07:36:42 +00:00
class AgentLoop :
"""
The agent loop is the core processing engine.
2026-02-13 15:10:07 +08:00
2026-02-01 07:36:42 +00:00
It:
1. Receives messages from the bus
2. Builds context with history, memory, skills
3. Calls the LLM
4. Executes tool calls
5. Sends responses back
"""
2026-02-13 15:10:07 +08:00
2026-02-01 07:36:42 +00:00
def __init__ (
self ,
bus : MessageBus ,
provider : LLMProvider ,
workspace : Path ,
model : str | None = None ,
max_iterations : int = 20 ,
2026-02-14 01:13:49 +00:00
temperature : float = 0.7 ,
2026-02-14 01:40:37 +00:00
max_tokens : int = 4096 ,
2026-02-12 15:02:52 +00:00
memory_window : int = 50 ,
2026-02-04 03:45:26 +00:00
brave_api_key : str | None = None ,
exec_config : "ExecToolConfig | None" = None ,
2026-02-05 15:09:51 +00:00
cron_service : "CronService | None" = None ,
2026-02-06 09:28:08 +00:00
restrict_to_workspace : bool = False ,
2026-02-08 05:06:41 +00:00
session_manager : SessionManager | None = None ,
2026-02-12 10:01:30 +01:00
mcp_servers : dict | None = None ,
2026-02-01 07:36:42 +00:00
):
2026-02-04 03:45:26 +00:00
from nanobot.config.schema import ExecToolConfig
2026-02-05 15:09:51 +00:00
from nanobot.cron.service import CronService
2026-02-01 07:36:42 +00:00
self . bus = bus
self . provider = provider
self . workspace = workspace
self . model = model or provider . get_default_model ()
self . max_iterations = max_iterations
2026-02-12 19:12:38 +08:00
self . temperature = temperature
2026-02-14 01:40:37 +00:00
self . max_tokens = max_tokens
2026-02-12 15:02:52 +00:00
self . memory_window = memory_window
2026-02-01 07:36:42 +00:00
self . brave_api_key = brave_api_key
2026-02-04 03:45:26 +00:00
self . exec_config = exec_config or ExecToolConfig ()
2026-02-05 15:09:51 +00:00
self . cron_service = cron_service
2026-02-06 09:28:08 +00:00
self . restrict_to_workspace = restrict_to_workspace
2026-02-13 15:10:07 +08:00
2026-02-01 07:36:42 +00:00
self . context = ContextBuilder ( workspace )
2026-02-08 05:06:41 +00:00
self . sessions = session_manager or SessionManager ( workspace )
2026-02-01 07:36:42 +00:00
self . tools = ToolRegistry ()
2026-02-01 16:28:45 +00:00
self . subagents = SubagentManager (
provider = provider ,
workspace = workspace ,
bus = bus ,
model = self . model ,
2026-02-14 01:40:37 +00:00
temperature = self . temperature ,
max_tokens = self . max_tokens ,
2026-02-01 16:28:45 +00:00
brave_api_key = brave_api_key ,
2026-02-04 03:45:26 +00:00
exec_config = self . exec_config ,
2026-02-06 09:28:08 +00:00
restrict_to_workspace = restrict_to_workspace ,
2026-02-01 16:28:45 +00:00
)
2026-02-01 07:36:42 +00:00
self . _running = False
2026-02-12 10:01:30 +01:00
self . _mcp_servers = mcp_servers or {}
self . _mcp_stack : AsyncExitStack | None = None
self . _mcp_connected = False
2026-02-01 07:36:42 +00:00
self . _register_default_tools ()
def _register_default_tools ( self ) -> None :
"""Register the default set of tools."""
2026-02-06 09:16:20 +00:00
# File tools (restrict to workspace if configured)
2026-02-06 09:28:08 +00:00
allowed_dir = self . workspace if self . restrict_to_workspace else None
2026-02-06 09:16:20 +00:00
self . tools . register ( ReadFileTool ( allowed_dir = allowed_dir ))
self . tools . register ( WriteFileTool ( allowed_dir = allowed_dir ))
self . tools . register ( EditFileTool ( allowed_dir = allowed_dir ))
self . tools . register ( ListDirTool ( allowed_dir = allowed_dir ))
2026-02-01 07:36:42 +00:00
# Shell tool
2026-02-04 03:45:26 +00:00
self . tools . register ( ExecTool (
working_dir = str ( self . workspace ),
timeout = self . exec_config . timeout ,
2026-02-06 09:28:08 +00:00
restrict_to_workspace = self . restrict_to_workspace ,
2026-02-04 03:45:26 +00:00
))
2026-02-01 07:36:42 +00:00
# Web tools
self . tools . register ( WebSearchTool ( api_key = self . brave_api_key ))
self . tools . register ( WebFetchTool ())
# Message tool
message_tool = MessageTool ( send_callback = self . bus . publish_outbound )
self . tools . register ( message_tool )
2026-02-01 16:28:45 +00:00
# Spawn tool (for subagents)
spawn_tool = SpawnTool ( manager = self . subagents )
self . tools . register ( spawn_tool )
2026-02-05 15:09:51 +00:00
# Cron tool (for scheduling)
if self . cron_service :
self . tools . register ( CronTool ( self . cron_service ))
2026-02-01 07:36:42 +00:00
2026-02-12 10:01:30 +01:00
async def _connect_mcp ( self ) -> None :
"""Connect to configured MCP servers (one-time, lazy)."""
if self . _mcp_connected or not self . _mcp_servers :
return
self . _mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
self . _mcp_stack = AsyncExitStack ()
await self . _mcp_stack . __aenter__ ()
await connect_mcp_servers ( self . _mcp_servers , self . tools , self . _mcp_stack )
2026-02-13 15:10:07 +08:00
def _set_tool_context ( self , channel : str , chat_id : str ) -> None :
"""Update context for all tools that need routing info."""
if message_tool := self . tools . get ( "message" ):
if isinstance ( message_tool , MessageTool ):
message_tool . set_context ( channel , chat_id )
if spawn_tool := self . tools . get ( "spawn" ):
if isinstance ( spawn_tool , SpawnTool ):
spawn_tool . set_context ( channel , chat_id )
if cron_tool := self . tools . get ( "cron" ):
if isinstance ( cron_tool , CronTool ):
cron_tool . set_context ( channel , chat_id )
2026-02-18 14:23:51 +00:00
@staticmethod
def _strip_think ( text : str | None ) -> str | None :
"""Remove <think>…</think> blocks that some models embed in content."""
if not text :
return None
return re . sub ( r "<think>[\s\S]*?</think>" , "" , text ) . strip () or None
@staticmethod
def _tool_hint ( tool_calls : list ) -> str :
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
def _fmt ( tc ):
val = next ( iter ( tc . arguments . values ()), None ) if tc . arguments else None
if not isinstance ( val , str ):
return tc . name
return f ' { tc . name } (" { val [: 40 ] } …")' if len ( val ) > 40 else f ' { tc . name } (" { val } ")'
return ", " . join ( _fmt ( tc ) for tc in tool_calls )
async def _run_agent_loop (
self ,
initial_messages : list [ dict ],
on_progress : Callable [[ str ], Awaitable [ None ]] | None = None ,
) -> tuple [ str | None , list [ str ]]:
2026-02-13 15:10:07 +08:00
"""
Run the agent iteration loop.
Args:
initial_messages: Starting messages for the LLM conversation.
2026-02-18 14:23:51 +00:00
on_progress: Optional callback to push intermediate content to the user.
2026-02-13 15:10:07 +08:00
Returns:
Tuple of (final_content, list_of_tools_used).
"""
messages = initial_messages
iteration = 0
final_content = None
tools_used : list [ str ] = []
while iteration < self . max_iterations :
iteration += 1
response = await self . provider . chat (
messages = messages ,
tools = self . tools . get_definitions (),
2026-02-14 01:22:17 +00:00
model = self . model ,
temperature = self . temperature ,
2026-02-14 01:40:37 +00:00
max_tokens = self . max_tokens ,
2026-02-13 15:10:07 +08:00
)
if response . has_tool_calls :
2026-02-18 14:23:51 +00:00
if on_progress :
clean = self . _strip_think ( response . content )
await on_progress ( clean or self . _tool_hint ( response . tool_calls ))
2026-02-13 15:10:07 +08:00
tool_call_dicts = [
{
"id" : tc . id ,
"type" : "function" ,
"function" : {
"name" : tc . name ,
"arguments" : json . dumps ( tc . arguments )
}
}
for tc in response . tool_calls
]
messages = self . context . add_assistant_message (
messages , response . content , tool_call_dicts ,
reasoning_content = response . reasoning_content ,
)
for tool_call in response . tool_calls :
tools_used . append ( tool_call . name )
args_str = json . dumps ( tool_call . arguments , ensure_ascii = False )
logger . info ( f "Tool call: { tool_call . name } ( { args_str [: 200 ] } )" )
result = await self . tools . execute ( tool_call . name , tool_call . arguments )
messages = self . context . add_tool_result (
messages , tool_call . id , tool_call . name , result
)
else :
2026-02-18 14:23:51 +00:00
final_content = self . _strip_think ( response . content )
2026-02-13 15:10:07 +08:00
break
return final_content , tools_used
2026-02-01 07:36:42 +00:00
async def run ( self ) -> None :
"""Run the agent loop, processing messages from the bus."""
self . _running = True
2026-02-12 10:01:30 +01:00
await self . _connect_mcp ()
2026-02-01 07:36:42 +00:00
logger . info ( "Agent loop started" )
2026-02-13 15:10:07 +08:00
2026-02-01 07:36:42 +00:00
while self . _running :
try :
msg = await asyncio . wait_for (
self . bus . consume_inbound (),
timeout = 1.0
)
try :
response = await self . _process_message ( msg )
if response :
await self . bus . publish_outbound ( response )
except Exception as e :
logger . error ( f "Error processing message: { e } " )
await self . bus . publish_outbound ( OutboundMessage (
channel = msg . channel ,
chat_id = msg . chat_id ,
content = f "Sorry, I encountered an error: { str ( e ) } "
))
except asyncio . TimeoutError :
continue
2026-02-15 07:00:27 +00:00
async def close_mcp ( self ) -> None :
2026-02-12 10:01:30 +01:00
"""Close MCP connections."""
if self . _mcp_stack :
try :
await self . _mcp_stack . aclose ()
except ( RuntimeError , BaseExceptionGroup ):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self . _mcp_stack = None
2026-02-01 07:36:42 +00:00
def stop ( self ) -> None :
"""Stop the agent loop."""
self . _running = False
logger . info ( "Agent loop stopping" )
2026-02-18 14:23:51 +00:00
async def _process_message (
self ,
msg : InboundMessage ,
session_key : str | None = None ,
on_progress : Callable [[ str ], Awaitable [ None ]] | None = None ,
) -> OutboundMessage | None :
2026-02-01 07:36:42 +00:00
"""
Process a single inbound message.
Args:
msg: The inbound message to process.
2026-02-12 15:02:52 +00:00
session_key: Override session key (used by process_direct).
2026-02-18 14:23:51 +00:00
on_progress: Optional callback for intermediate output (defaults to bus publish).
2026-02-01 07:36:42 +00:00
Returns:
The response message, or None if no response needed.
"""
2026-02-14 01:40:37 +00:00
# System messages route back via chat_id ("channel:chat_id")
2026-02-01 16:28:45 +00:00
if msg . channel == "system" :
return await self . _process_system_message ( msg )
2026-02-07 08:10:05 +00:00
preview = msg . content [: 80 ] + "..." if len ( msg . content ) > 80 else msg . content
logger . info ( f "Processing message from { msg . channel } : { msg . sender_id } : { preview } " )
2026-02-01 07:36:42 +00:00
2026-02-13 03:30:21 +00:00
key = session_key or msg . session_key
session = self . sessions . get_or_create ( key )
2026-02-01 07:36:42 +00:00
2026-02-13 03:30:21 +00:00
# Handle slash commands
cmd = msg . content . strip () . lower ()
if cmd == "/new" :
2026-02-13 15:14:22 +08:00
# Capture messages before clearing (avoid race condition with background task)
messages_to_archive = session . messages . copy ()
2026-02-13 03:30:21 +00:00
session . clear ()
self . sessions . save ( session )
2026-02-14 01:40:37 +00:00
self . sessions . invalidate ( session . key )
2026-02-13 15:14:22 +08:00
async def _consolidate_and_cleanup ():
temp_session = Session ( key = session . key )
temp_session . messages = messages_to_archive
await self . _consolidate_memory ( temp_session , archive_all = True )
asyncio . create_task ( _consolidate_and_cleanup ())
2026-02-13 03:30:21 +00:00
return OutboundMessage ( channel = msg . channel , chat_id = msg . chat_id ,
2026-02-13 15:14:22 +08:00
content = "New session started. Memory consolidation in progress." )
2026-02-13 03:30:21 +00:00
if cmd == "/help" :
return OutboundMessage ( channel = msg . channel , chat_id = msg . chat_id ,
content = "🐈 nanobot commands: \n /new — Start a new conversation \n /help — Show available commands" )
2026-02-01 07:36:42 +00:00
2026-02-12 15:02:52 +00:00
if len ( session . messages ) > self . memory_window :
2026-02-13 15:14:22 +08:00
asyncio . create_task ( self . _consolidate_memory ( session ))
2026-02-13 15:10:07 +08:00
self . _set_tool_context ( msg . channel , msg . chat_id )
initial_messages = self . context . build_messages (
2026-02-14 01:40:37 +00:00
history = session . get_history ( max_messages = self . memory_window ),
2026-02-02 15:32:12 +08:00
current_message = msg . content ,
media = msg . media if msg . media else None ,
2026-02-05 15:09:51 +00:00
channel = msg . channel ,
chat_id = msg . chat_id ,
2026-02-01 07:36:42 +00:00
)
2026-02-18 14:23:51 +00:00
async def _bus_progress ( content : str ) -> None :
await self . bus . publish_outbound ( OutboundMessage (
channel = msg . channel , chat_id = msg . chat_id , content = content ,
metadata = msg . metadata or {},
))
final_content , tools_used = await self . _run_agent_loop (
initial_messages , on_progress = on_progress or _bus_progress ,
)
2026-02-13 15:10:07 +08:00
2026-02-01 07:36:42 +00:00
if final_content is None :
final_content = "I've completed processing but have no response to give."
2026-02-07 08:10:05 +00:00
preview = final_content [: 120 ] + "..." if len ( final_content ) > 120 else final_content
logger . info ( f "Response to { msg . channel } : { msg . sender_id } : { preview } " )
2026-02-01 07:36:42 +00:00
session . add_message ( "user" , msg . content )
2026-02-12 15:02:52 +00:00
session . add_message ( "assistant" , final_content ,
tools_used = tools_used if tools_used else None )
2026-02-01 07:36:42 +00:00
self . sessions . save ( session )
return OutboundMessage (
channel = msg . channel ,
chat_id = msg . chat_id ,
2026-02-04 23:26:20 +05:30
content = final_content ,
2026-02-09 11:39:13 +00:00
metadata = msg . metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
2026-02-01 07:36:42 +00:00
)
2026-02-01 16:28:45 +00:00
async def _process_system_message ( self , msg : InboundMessage ) -> OutboundMessage | None :
"""
Process a system message (e.g., subagent announce).
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger . info ( f "Processing system message from { msg . sender_id } " )
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg . chat_id :
parts = msg . chat_id . split ( ":" , 1 )
origin_channel = parts [ 0 ]
origin_chat_id = parts [ 1 ]
else :
# Fallback
origin_channel = "cli"
origin_chat_id = msg . chat_id
session_key = f " { origin_channel } : { origin_chat_id } "
session = self . sessions . get_or_create ( session_key )
2026-02-13 15:10:07 +08:00
self . _set_tool_context ( origin_channel , origin_chat_id )
initial_messages = self . context . build_messages (
2026-02-14 01:40:37 +00:00
history = session . get_history ( max_messages = self . memory_window ),
2026-02-05 15:09:51 +00:00
current_message = msg . content ,
channel = origin_channel ,
chat_id = origin_chat_id ,
2026-02-01 16:28:45 +00:00
)
2026-02-13 15:10:07 +08:00
final_content , _ = await self . _run_agent_loop ( initial_messages )
2026-02-01 16:28:45 +00:00
if final_content is None :
final_content = "Background task completed."
session . add_message ( "user" , f "[System: { msg . sender_id } ] { msg . content } " )
session . add_message ( "assistant" , final_content )
self . sessions . save ( session )
return OutboundMessage (
channel = origin_channel ,
chat_id = origin_chat_id ,
content = final_content
)
2026-02-13 03:30:21 +00:00
async def _consolidate_memory ( self , session , archive_all : bool = False ) -> None :
2026-02-13 15:10:07 +08:00
"""Consolidate old messages into MEMORY.md + HISTORY.md.
Args:
archive_all: If True, clear all messages and reset session (for /new command).
If False, only write to files without modifying session.
"""
2026-02-12 15:02:52 +00:00
memory = MemoryStore ( self . workspace )
2026-02-13 15:10:07 +08:00
2026-02-13 03:30:21 +00:00
if archive_all :
2026-02-14 01:40:37 +00:00
old_messages = session . messages
keep_count = 0
2026-02-13 15:10:07 +08:00
logger . info ( f "Memory consolidation (archive_all): { len ( session . messages ) } total messages archived" )
2026-02-13 03:30:21 +00:00
else :
2026-02-13 15:10:07 +08:00
keep_count = self . memory_window // 2
if len ( session . messages ) <= keep_count :
logger . debug ( f "Session { session . key } : No consolidation needed (messages= { len ( session . messages ) } , keep= { keep_count } )" )
return
messages_to_process = len ( session . messages ) - session . last_consolidated
if messages_to_process <= 0 :
logger . debug ( f "Session { session . key } : No new messages to consolidate (last_consolidated= { session . last_consolidated } , total= { len ( session . messages ) } )" )
return
old_messages = session . messages [ session . last_consolidated : - keep_count ]
if not old_messages :
return
logger . info ( f "Memory consolidation started: { len ( session . messages ) } total, { len ( old_messages ) } new to consolidate, { keep_count } keep" )
2026-02-12 15:02:52 +00:00
lines = []
for m in old_messages :
if not m . get ( "content" ):
continue
tools = f " [tools: { ', ' . join ( m [ 'tools_used' ]) } ]" if m . get ( "tools_used" ) else ""
lines . append ( f "[ { m . get ( 'timestamp' , '?' )[: 16 ] } ] { m [ 'role' ] . upper () }{ tools } : { m [ 'content' ] } " )
conversation = " \n " . join ( lines )
current_memory = memory . read_long_term ()
prompt = f """You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys:
1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.
2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.
## Current Long-term Memory
{ current_memory or "(empty)" }
## Conversation to Process
{ conversation }
Respond with ONLY valid JSON, no markdown fences."""
try :
response = await self . provider . chat (
messages = [
{ "role" : "system" , "content" : "You are a memory consolidation agent. Respond only with valid JSON." },
{ "role" : "user" , "content" : prompt },
],
model = self . model ,
)
text = ( response . content or "" ) . strip ()
2026-02-15 08:11:33 +00:00
if not text :
logger . warning ( "Memory consolidation: LLM returned empty response, skipping" )
return
2026-02-12 15:02:52 +00:00
if text . startswith ( "```" ):
text = text . split ( " \n " , 1 )[ - 1 ] . rsplit ( "```" , 1 )[ 0 ] . strip ()
2026-02-15 08:11:33 +00:00
result = json_repair . loads ( text )
if not isinstance ( result , dict ):
logger . warning ( f "Memory consolidation: unexpected response type, skipping. Response: { text [: 200 ] } " )
return
2026-02-12 15:02:52 +00:00
if entry := result . get ( "history_entry" ):
memory . append_history ( entry )
if update := result . get ( "memory_update" ):
if update != current_memory :
memory . write_long_term ( update )
2026-02-13 15:10:07 +08:00
if archive_all :
session . last_consolidated = 0
else :
session . last_consolidated = len ( session . messages ) - keep_count
2026-02-14 01:40:37 +00:00
logger . info ( f "Memory consolidation done: { len ( session . messages ) } messages, last_consolidated= { session . last_consolidated } " )
2026-02-12 15:02:52 +00:00
except Exception as e :
logger . error ( f "Memory consolidation failed: { e } " )
2026-02-05 15:09:51 +00:00
async def process_direct (
self ,
content : str ,
session_key : str = "cli:direct" ,
channel : str = "cli" ,
chat_id : str = "direct" ,
2026-02-18 14:23:51 +00:00
on_progress : Callable [[ str ], Awaitable [ None ]] | None = None ,
2026-02-05 15:09:51 +00:00
) -> str :
2026-02-01 07:36:42 +00:00
"""
2026-02-05 15:09:51 +00:00
Process a message directly (for CLI or cron usage).
2026-02-01 07:36:42 +00:00
Args:
content: The message content.
2026-02-12 15:02:52 +00:00
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
2026-02-18 14:23:51 +00:00
on_progress: Optional callback for intermediate output.
2026-02-01 07:36:42 +00:00
Returns:
The agent's response.
"""
2026-02-12 10:01:30 +01:00
await self . _connect_mcp ()
2026-02-01 07:36:42 +00:00
msg = InboundMessage (
2026-02-05 15:09:51 +00:00
channel = channel ,
2026-02-01 07:36:42 +00:00
sender_id = "user" ,
2026-02-05 15:09:51 +00:00
chat_id = chat_id ,
2026-02-01 07:36:42 +00:00
content = content
)
2026-02-18 14:23:51 +00:00
response = await self . _process_message ( msg , session_key = session_key , on_progress = on_progress )
2026-02-01 07:36:42 +00:00
return response . content if response else ""