Files
nanobot/nanobot/agent/context.py
T

238 lines
7.9 KiB
Python
Raw Normal View History

2026-02-01 07:36:42 +00:00
"""Context builder for assembling agent prompts."""
import base64
import mimetypes
import platform
2026-02-01 07:36:42 +00:00
from pathlib import Path
from typing import Any
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
class ContextBuilder:
"""
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"]
def __init__(self, workspace: Path):
self.workspace = workspace
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""
Build the system prompt from bootstrap files, memory, and skills.
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()
if bootstrap:
parts.append(bootstrap)
# Memory context
memory = self.memory.get_memory_context()
if 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()
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
if 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()
if skills_summary:
parts.append(f"""# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""")
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
import time as _time
2026-02-01 07:36:42 +00:00
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
2026-02-01 07:36:42 +00:00
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
2026-02-01 07:36:42 +00:00
return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
2026-02-01 16:28:45 +00:00
- Spawn subagents for complex background tasks
2026-02-01 07:36:42 +00:00
## Current Time
{now} ({tz})
2026-02-01 07:36:42 +00:00
## Runtime
{runtime}
2026-02-01 07:36:42 +00:00
## Workspace
Your workspace is at: {workspace_path}
- Memory files: {workspace_path}/memory/MEMORY.md
- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
2026-02-01 07:36:42 +00:00
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""
parts = []
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else ""
def build_messages(
self,
history: list[dict[str, Any]],
current_message: str,
skill_names: list[str] | None = None,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
2026-02-01 07:36:42 +00:00
) -> list[dict[str, Any]]:
"""
Build the complete message list for an LLM call.
2026-02-01 07:36:42 +00:00
Args:
history: Previous conversation messages.
current_message: The new user message.
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.
2026-02-01 07:36:42 +00:00
Returns:
List of messages including system prompt.
"""
messages = []
2026-02-01 07:36:42 +00:00
# System prompt
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}"
2026-02-01 07:36:42 +00:00
messages.append({"role": "system", "content": system_prompt})
2026-02-01 07:36:42 +00:00
# History
messages.extend(history)
# Current message (with optional image attachments)
user_content = self._build_user_content(current_message, media)
messages.append({"role": "user", "content": user_content})
2026-02-01 07:36:42 +00:00
return messages
2026-02-02 13:29:38 +00:00
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."""
if not media:
return text
2026-02-02 13:29:38 +00:00
images = []
for path in media:
2026-02-02 13:29:38 +00:00
p = Path(path)
mime, _ = mimetypes.guess_type(path)
if not p.is_file() or not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(p.read_bytes()).decode()
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
if not images:
return text
2026-02-02 13:29:38 +00:00
return images + [{"type": "text", "text": text}]
2026-02-01 07:36:42 +00:00
def add_tool_result(
self,
messages: list[dict[str, Any]],
tool_call_id: str,
tool_name: str,
result: str
) -> list[dict[str, Any]]:
"""
Add a tool result to the message list.
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
def add_assistant_message(
self,
messages: list[dict[str, Any]],
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
2026-02-01 07:36:42 +00:00
) -> list[dict[str, Any]]:
"""
Add an assistant message to the message list.
Args:
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
2026-02-01 07:36:42 +00:00
Returns:
Updated message list.
"""
2026-02-01 16:28:45 +00:00
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
2026-02-01 07:36:42 +00:00
if tool_calls:
msg["tool_calls"] = tool_calls
# Thinking models reject history without this
if reasoning_content:
msg["reasoning_content"] = reasoning_content
2026-02-01 07:36:42 +00:00
messages.append(msg)
return messages