From 83826f3904e727f994df1b5b5f2cee1d2dfe0131 Mon Sep 17 00:00:00 2001 From: Hua Date: Fri, 13 Mar 2026 11:29:08 +0800 Subject: [PATCH] Add persona and language command localization --- nanobot/agent/context.py | 70 +++++++-- nanobot/agent/i18n.py | 91 +++++++++++ nanobot/agent/loop.py | 239 ++++++++++++++++++++++++++--- nanobot/agent/memory.py | 29 +++- nanobot/agent/personas.py | 66 ++++++++ nanobot/channels/telegram.py | 43 +++--- nanobot/locales/en.json | 47 ++++++ nanobot/locales/zh.json | 47 ++++++ pyproject.toml | 1 + tests/test_context_prompt_cache.py | 26 ++++ tests/test_persona_commands.py | 138 +++++++++++++++++ 11 files changed, 742 insertions(+), 55 deletions(-) create mode 100644 nanobot/agent/i18n.py create mode 100644 nanobot/agent/personas.py create mode 100644 nanobot/locales/en.json create mode 100644 nanobot/locales/zh.json create mode 100644 tests/test_persona_commands.py diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index e47fcb8..1b468e6 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -8,7 +8,15 @@ from datetime import datetime from pathlib import Path from typing import Any +from nanobot.agent.i18n import language_label, resolve_language from nanobot.agent.memory import MemoryStore +from nanobot.agent.personas import ( + DEFAULT_PERSONA, + list_personas, + persona_workspace, + personas_root, + resolve_persona_name, +) from nanobot.agent.skills import SkillsLoader from nanobot.utils.helpers import build_assistant_message, detect_image_mime @@ -21,18 +29,36 @@ class ContextBuilder: 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 identity, bootstrap files, memory, and skills.""" - parts = [self._get_identity()] + def list_personas(self) -> list[str]: + """Return the personas available for this workspace.""" + return list_personas(self.workspace) - bootstrap = self._load_bootstrap_files() + def find_persona(self, persona: str | None) -> str | None: + """Resolve a persona name without applying a default fallback.""" + return resolve_persona_name(self.workspace, persona) + + def resolve_persona(self, persona: str | None) -> str: + """Return a canonical persona name, defaulting to the built-in persona.""" + return self.find_persona(persona) or DEFAULT_PERSONA + + def build_system_prompt( + self, + skill_names: list[str] | None = None, + persona: str | None = None, + language: str | None = None, + ) -> str: + """Build the system prompt from identity, bootstrap files, memory, and skills.""" + active_persona = self.resolve_persona(persona) + active_language = resolve_language(language) + parts = [self._get_identity(active_persona, active_language)] + + bootstrap = self._load_bootstrap_files(active_persona) if bootstrap: parts.append(bootstrap) - memory = self.memory.get_memory_context() + memory = self._memory_store(active_persona).get_memory_context() if memory: parts.append(f"# Memory\n\n{memory}") @@ -53,9 +79,12 @@ Skills with available="false" need dependencies installed first - you can try in return "\n\n---\n\n".join(parts) - def _get_identity(self) -> str: + def _get_identity(self, persona: str, language: str) -> str: """Get the core identity section.""" workspace_path = str(self.workspace.expanduser().resolve()) + active_workspace = persona_workspace(self.workspace, persona) + persona_path = str(active_workspace.expanduser().resolve()) + language_name = language_label(language, language) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" @@ -81,10 +110,18 @@ You are nanobot, a helpful AI assistant. ## Workspace Your workspace is at: {workspace_path} -- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here) -- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. +- Long-term memory: {persona_path}/memory/MEMORY.md (write important facts here) +- History log: {persona_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md +## Persona +Current persona: {persona} +- Persona workspace: {persona_path} + +## Language +Preferred response language: {language_name} +- Use this language for assistant replies and command/status text unless the user explicitly asks for another language. + {platform_policy} ## nanobot Guidelines @@ -106,12 +143,21 @@ Reply directly with text for conversations. Only use the 'message' tool to send lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) - def _load_bootstrap_files(self) -> str: + def _memory_store(self, persona: str) -> MemoryStore: + """Return the memory store for the active persona.""" + return MemoryStore(persona_workspace(self.workspace, persona)) + + def _load_bootstrap_files(self, persona: str) -> str: """Load all bootstrap files from workspace.""" parts = [] + persona_dir = None if persona == DEFAULT_PERSONA else personas_root(self.workspace) / persona for filename in self.BOOTSTRAP_FILES: file_path = self.workspace / filename + if persona_dir: + persona_file = persona_dir / filename + if persona_file.exists(): + file_path = persona_file if file_path.exists(): content = file_path.read_text(encoding="utf-8") parts.append(f"## {filename}\n\n{content}") @@ -126,6 +172,8 @@ Reply directly with text for conversations. Only use the 'message' tool to send media: list[str] | None = None, channel: str | None = None, chat_id: str | None = None, + persona: str | None = None, + language: str | None = None, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" runtime_ctx = self._build_runtime_context(channel, chat_id) @@ -139,7 +187,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send merged = [{"type": "text", "text": runtime_ctx}] + user_content return [ - {"role": "system", "content": self.build_system_prompt(skill_names)}, + {"role": "system", "content": self.build_system_prompt(skill_names, persona=persona, language=language)}, *history, {"role": "user", "content": merged}, ] diff --git a/nanobot/agent/i18n.py b/nanobot/agent/i18n.py new file mode 100644 index 0000000..8b4fa25 --- /dev/null +++ b/nanobot/agent/i18n.py @@ -0,0 +1,91 @@ +"""Minimal session-level localization helpers.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from importlib.resources import files as pkg_files +from typing import Any + +DEFAULT_LANGUAGE = "en" +SUPPORTED_LANGUAGES = ("en", "zh") + +_LANGUAGE_ALIASES = { + "en": "en", + "en-us": "en", + "en-gb": "en", + "english": "en", + "zh": "zh", + "zh-cn": "zh", + "zh-hans": "zh", + "zh-sg": "zh", + "cn": "zh", + "chinese": "zh", + "中文": "zh", +} + +@lru_cache(maxsize=len(SUPPORTED_LANGUAGES)) +def _load_locale(language: str) -> dict[str, Any]: + """Load one locale file from packaged JSON resources.""" + lang = resolve_language(language) + locale_file = pkg_files("nanobot") / "locales" / f"{lang}.json" + with locale_file.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def normalize_language_code(value: Any) -> str | None: + """Normalize a language identifier into a supported code.""" + if not isinstance(value, str): + return None + cleaned = value.strip().lower() + if not cleaned: + return None + return _LANGUAGE_ALIASES.get(cleaned) + + +def resolve_language(value: Any) -> str: + """Resolve the active language, defaulting to English.""" + return normalize_language_code(value) or DEFAULT_LANGUAGE + + +def list_languages() -> list[str]: + """Return supported language codes in display order.""" + return list(SUPPORTED_LANGUAGES) + + +def language_label(code: str, ui_language: str | None = None) -> str: + """Return a display label for a language code.""" + active_ui = resolve_language(ui_language) + normalized = resolve_language(code) + locale = _load_locale(active_ui) + return f"{normalized} ({locale['language_labels'][normalized]})" + + +def text(language: Any, key: str, **kwargs: Any) -> str: + """Return localized UI text.""" + active = resolve_language(language) + template = _load_locale(active)["texts"][key] + return template.format(**kwargs) + + +def help_lines(language: Any) -> list[str]: + """Return localized slash-command help lines.""" + active = resolve_language(language) + return [ + text(active, "help_header"), + text(active, "cmd_new"), + text(active, "cmd_lang_current"), + text(active, "cmd_lang_list"), + text(active, "cmd_lang_set"), + text(active, "cmd_persona_current"), + text(active, "cmd_persona_list"), + text(active, "cmd_persona_set"), + text(active, "cmd_stop"), + text(active, "cmd_restart"), + text(active, "cmd_help"), + ] + + +def telegram_command_descriptions(language: Any) -> dict[str, str]: + """Return Telegram command descriptions for a locale.""" + return _load_locale(resolve_language(language))["telegram_commands"] diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a7303af..7129818 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -14,6 +14,15 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger from nanobot.agent.context import ContextBuilder +from nanobot.agent.i18n import ( + DEFAULT_LANGUAGE, + help_lines, + language_label, + list_languages, + normalize_language_code, + resolve_language, + text, +) from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.cron import CronTool @@ -119,6 +128,52 @@ class AgentLoop: ) self._register_default_tools() + @staticmethod + def _command_name(content: str) -> str: + """Return the normalized slash command name.""" + parts = content.strip().split(None, 1) + return parts[0].lower() if parts else "" + + def _get_session_persona(self, session: Session) -> str: + """Return the active persona name for a session.""" + return self.context.resolve_persona(session.metadata.get("persona")) + + def _get_session_language(self, session: Session) -> str: + """Return the active language for a session.""" + metadata = getattr(session, "metadata", {}) + raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE + return resolve_language(raw) + + def _set_session_persona(self, session: Session, persona: str) -> None: + """Persist the selected persona for a session.""" + if persona == "default": + session.metadata.pop("persona", None) + else: + session.metadata["persona"] = persona + + def _set_session_language(self, session: Session, language: str) -> None: + """Persist the selected language for a session.""" + if language == DEFAULT_LANGUAGE: + session.metadata.pop("language", None) + else: + session.metadata["language"] = language + + def _persona_usage(self, language: str) -> str: + """Return persona command help text.""" + return "\n".join([ + text(language, "cmd_persona_current"), + text(language, "cmd_persona_list"), + text(language, "cmd_persona_set"), + ]) + + def _language_usage(self, language: str) -> str: + """Return language command help text.""" + return "\n".join([ + text(language, "cmd_lang_current"), + text(language, "cmd_lang_list"), + text(language, "cmd_lang_set"), + ]) + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = self.workspace if self.restrict_to_workspace else None @@ -275,7 +330,7 @@ class AgentLoop: except asyncio.TimeoutError: continue - cmd = msg.content.strip().lower() + cmd = self._command_name(msg.content) if cmd == "/stop": await self._handle_stop(msg) elif cmd == "/restart": @@ -296,15 +351,19 @@ class AgentLoop: 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." + session = self.sessions.get_or_create(msg.session_key) + language = self._get_session_language(session) + content = text(language, "stopped_tasks", count=total) if total else text(language, "no_active_task") await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, )) async def _handle_restart(self, msg: InboundMessage) -> None: """Restart the process in-place via os.execv.""" + session = self.sessions.get_or_create(msg.session_key) + language = self._get_session_language(session) await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", + channel=msg.channel, chat_id=msg.chat_id, content=text(language, "restarting"), )) async def _do_restart(): @@ -334,9 +393,145 @@ class AgentLoop: 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.", + content=text(self._get_session_language(self.sessions.get_or_create(msg.session_key)), "generic_error"), )) + async def _handle_language_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: + """Handle session-scoped language switching commands.""" + current = self._get_session_language(session) + parts = msg.content.strip().split() + if len(parts) == 1 or parts[1].lower() == "current": + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(current, "current_language", language_name=language_label(current, current)), + ) + + subcommand = parts[1].lower() + if subcommand == "list": + items = "\n".join( + f"- {language_label(code, current)}" + + (f" ({text(current, 'current_marker')})" if code == current else "") + for code in list_languages() + ) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(current, "available_languages", items=items), + ) + + if subcommand != "set" or len(parts) < 3: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=self._language_usage(current), + ) + + target = normalize_language_code(parts[2]) + if target is None: + languages = ", ".join(language_label(code, current) for code in list_languages()) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(current, "unknown_language", name=parts[2], languages=languages), + ) + + if target == current: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(current, "language_already_active", language_name=language_label(target, current)), + ) + + self._set_session_language(session, target) + self.sessions.save(session) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(target, "switched_language", language_name=language_label(target, target)), + ) + + async def _handle_persona_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: + """Handle session-scoped persona management commands.""" + language = self._get_session_language(session) + parts = msg.content.strip().split() + if len(parts) == 1 or parts[1].lower() == "current": + current = self._get_session_persona(session) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "current_persona", persona=current), + ) + + subcommand = parts[1].lower() + if subcommand == "list": + current = self._get_session_persona(session) + marker = text(language, "current_marker") + personas = [ + f"{name} ({marker})" if name == current else name + for name in self.context.list_personas() + ] + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "available_personas", items="\n".join(f"- {name}" for name in personas)), + ) + + if subcommand != "set" or len(parts) < 3: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=self._persona_usage(language), + ) + + target = self.context.find_persona(parts[2]) + if target is None: + personas = ", ".join(self.context.list_personas()) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text( + language, + "unknown_persona", + name=parts[2], + personas=personas, + path=self.workspace / "personas" / parts[2], + ), + ) + + current = self._get_session_persona(session) + if target == current: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "persona_already_active", persona=target), + ) + + try: + if not await self.memory_consolidator.archive_unconsolidated(session): + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "memory_archival_failed_persona"), + ) + except Exception: + logger.exception("/persona archival failed for {}", session.key) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "memory_archival_failed_persona"), + ) + + session.clear() + self._set_session_persona(session, target) + self.sessions.save(session) + self.sessions.invalidate(session.key) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "switched_persona", persona=target), + ) + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -365,12 +560,18 @@ class AgentLoop: logger.info("Processing system message from {}", msg.sender_id) key = f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) + persona = self._get_session_persona(session) + language = self._get_session_language(session) await self.memory_consolidator.maybe_consolidate_by_tokens(session) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) history = session.get_history(max_messages=0) messages = self.context.build_messages( history=history, - current_message=msg.content, channel=channel, chat_id=chat_id, + current_message=msg.content, + channel=channel, + chat_id=chat_id, + persona=persona, + language=language, ) final_content, _, all_msgs = await self._run_agent_loop(messages) self._save_turn(session, all_msgs, 1 + len(history)) @@ -384,40 +585,39 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + persona = self._get_session_persona(session) + language = self._get_session_language(session) # Slash commands - cmd = msg.content.strip().lower() + cmd = self._command_name(msg.content) if cmd == "/new": try: if not await self.memory_consolidator.archive_unconsolidated(session): return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content="Memory archival failed, session not cleared. Please try again.", + content=text(language, "memory_archival_failed_session"), ) except Exception: logger.exception("/new archival failed for {}", session.key) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content="Memory archival failed, session not cleared. Please try again.", + content=text(language, "memory_archival_failed_session"), ) session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="New session started.") + content=text(language, "new_session_started")) + if cmd in {"/lang", "/language"}: + return await self._handle_language_command(msg, session) + if cmd == "/persona": + return await self._handle_persona_command(msg, session) if cmd == "/help": - lines = [ - "🐈 nanobot commands:", - "/new — Start a new conversation", - "/stop — Stop the current task", - "/restart — Restart the bot", - "/help — Show available commands", - ] return OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines), + channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)), ) await self.memory_consolidator.maybe_consolidate_by_tokens(session) @@ -431,7 +631,10 @@ class AgentLoop: history=history, current_message=msg.content, media=msg.media if msg.media else None, - channel=msg.channel, chat_id=msg.chat_id, + channel=msg.channel, + chat_id=msg.chat_id, + persona=persona, + language=language, ) async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index e7eac88..2f306d9 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any, Callable from loguru import logger +from nanobot.agent.i18n import DEFAULT_LANGUAGE, resolve_language +from nanobot.agent.personas import DEFAULT_PERSONA, persona_workspace, resolve_persona_name from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain if TYPE_CHECKING: @@ -71,6 +73,7 @@ def _is_tool_choice_unsupported(content: str | None) -> bool: return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS) + class MemoryStore: """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" @@ -195,7 +198,7 @@ class MemoryConsolidator: build_messages: Callable[..., list[dict[str, Any]]], get_tool_definitions: Callable[[], list[dict[str, Any]]], ): - self.store = MemoryStore(workspace) + self.workspace = workspace self.provider = provider self.model = model self.sessions = sessions @@ -204,13 +207,27 @@ class MemoryConsolidator: self._get_tool_definitions = get_tool_definitions self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() + def _get_persona(self, session: Session) -> str: + """Resolve the active persona for a session.""" + return resolve_persona_name(self.workspace, session.metadata.get("persona")) or DEFAULT_PERSONA + + def _get_language(self, session: Session) -> str: + """Resolve the active language for a session.""" + metadata = getattr(session, "metadata", {}) + raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE + return resolve_language(raw) + + def _get_store(self, session: Session) -> MemoryStore: + """Return the memory store associated with the active persona.""" + return MemoryStore(persona_workspace(self.workspace, self._get_persona(session))) + def get_lock(self, session_key: str) -> asyncio.Lock: """Return the shared consolidation lock for one session.""" return self._locks.setdefault(session_key, asyncio.Lock()) - async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool: + async def consolidate_messages(self, session: Session, messages: list[dict[str, object]]) -> bool: """Archive a selected message chunk into persistent memory.""" - return await self.store.consolidate(messages, self.provider, self.model) + return await self._get_store(session).consolidate(messages, self.provider, self.model) def pick_consolidation_boundary( self, @@ -243,6 +260,8 @@ class MemoryConsolidator: current_message="[token-probe]", channel=channel, chat_id=chat_id, + persona=self._get_persona(session), + language=self._get_language(session), ) return estimate_prompt_tokens_chain( self.provider, @@ -258,7 +277,7 @@ class MemoryConsolidator: snapshot = session.messages[session.last_consolidated:] if not snapshot: return True - return await self.consolidate_messages(snapshot) + return await self.consolidate_messages(session, snapshot) async def maybe_consolidate_by_tokens(self, session: Session) -> None: """Loop: archive old messages until prompt fits within half the context window.""" @@ -308,7 +327,7 @@ class MemoryConsolidator: source, len(chunk), ) - if not await self.consolidate_messages(chunk): + if not await self.consolidate_messages(session, chunk): return session.last_consolidated = end_idx self.sessions.save(session) diff --git a/nanobot/agent/personas.py b/nanobot/agent/personas.py new file mode 100644 index 0000000..3f2572f --- /dev/null +++ b/nanobot/agent/personas.py @@ -0,0 +1,66 @@ +"""Helpers for resolving session personas within a workspace.""" + +from __future__ import annotations + +import re +from pathlib import Path + +DEFAULT_PERSONA = "default" +PERSONAS_DIRNAME = "personas" +_VALID_PERSONA_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +def normalize_persona_name(name: str | None) -> str | None: + """Normalize a user-supplied persona name.""" + if not isinstance(name, str): + return None + + cleaned = name.strip() + if not cleaned: + return None + if cleaned.lower() == DEFAULT_PERSONA: + return DEFAULT_PERSONA + if not _VALID_PERSONA_RE.fullmatch(cleaned): + return None + return cleaned + + +def personas_root(workspace: Path) -> Path: + """Return the workspace-local persona root directory.""" + return workspace / PERSONAS_DIRNAME + + +def list_personas(workspace: Path) -> list[str]: + """List available personas, always including the built-in default persona.""" + personas: dict[str, str] = {DEFAULT_PERSONA.lower(): DEFAULT_PERSONA} + root = personas_root(workspace) + if root.exists(): + for child in root.iterdir(): + if not child.is_dir(): + continue + normalized = normalize_persona_name(child.name) + if normalized is None: + continue + personas.setdefault(normalized.lower(), child.name) + + return sorted(personas.values(), key=lambda value: (value.lower() != DEFAULT_PERSONA, value.lower())) + + +def resolve_persona_name(workspace: Path, name: str | None) -> str | None: + """Resolve a persona name to the canonical workspace directory name.""" + normalized = normalize_persona_name(name) + if normalized is None: + return None + if normalized == DEFAULT_PERSONA: + return DEFAULT_PERSONA + + available = {persona.lower(): persona for persona in list_personas(workspace)} + return available.get(normalized.lower()) + + +def persona_workspace(workspace: Path, persona: str | None) -> Path: + """Return the effective workspace root for a persona.""" + resolved = resolve_persona_name(workspace, persona) + if resolved in (None, DEFAULT_PERSONA): + return workspace + return personas_root(workspace) / resolved diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 916685b..cfb05e6 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -14,6 +14,7 @@ from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus +from nanobot.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import TelegramConfig @@ -158,14 +159,7 @@ class TelegramChannel(BaseChannel): name = "telegram" display_name = "Telegram" - # Commands registered with Telegram's command menu - BOT_COMMANDS = [ - BotCommand("start", "Start the bot"), - BotCommand("new", "Start a new conversation"), - BotCommand("stop", "Stop the current task"), - BotCommand("help", "Show available commands"), - BotCommand("restart", "Restart the bot"), - ] + COMMAND_NAMES = ("start", "new", "lang", "persona", "stop", "help", "restart") def __init__(self, config: TelegramConfig, bus: MessageBus): super().__init__(config, bus) @@ -198,6 +192,17 @@ class TelegramChannel(BaseChannel): return sid in allow_list or username in allow_list + @classmethod + def _build_bot_commands(cls, language: str) -> list[BotCommand]: + """Build localized command menu entries.""" + labels = telegram_command_descriptions(language) + return [BotCommand(name, labels[name]) for name in cls.COMMAND_NAMES] + + @staticmethod + def _preferred_language(user) -> str: + """Map Telegram's user language code to a supported locale.""" + return normalize_language_code(getattr(user, "language_code", None)) or "en" + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: @@ -221,6 +226,8 @@ class TelegramChannel(BaseChannel): # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) + self._app.add_handler(CommandHandler("lang", self._forward_command)) + self._app.add_handler(CommandHandler("persona", self._forward_command)) self._app.add_handler(CommandHandler("stop", self._forward_command)) self._app.add_handler(CommandHandler("restart", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) @@ -247,7 +254,8 @@ class TelegramChannel(BaseChannel): logger.info("Telegram bot @{} connected", bot_info.username) try: - await self._app.bot.set_my_commands(self.BOT_COMMANDS) + await self._app.bot.set_my_commands(self._build_bot_commands("en")) + await self._app.bot.set_my_commands(self._build_bot_commands("zh"), language_code="zh-hans") logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) @@ -420,22 +428,15 @@ class TelegramChannel(BaseChannel): return user = update.effective_user - await update.message.reply_text( - f"👋 Hi {user.first_name}! I'm nanobot.\n\n" - "Send me a message and I'll respond!\n" - "Type /help to see available commands." - ) + language = self._preferred_language(user) + await update.message.reply_text(text(language, "start_greeting", name=user.first_name)) async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /help command, bypassing ACL so all users can access it.""" - if not update.message: + if not update.message or not update.effective_user: return - await update.message.reply_text( - "🐈 nanobot commands:\n" - "/new — Start a new conversation\n" - "/stop — Stop the current task\n" - "/help — Show available commands" - ) + language = self._preferred_language(update.effective_user) + await update.message.reply_text("\n".join(help_lines(language))) @staticmethod def _sender_id(user) -> str: diff --git a/nanobot/locales/en.json b/nanobot/locales/en.json new file mode 100644 index 0000000..b86d2a9 --- /dev/null +++ b/nanobot/locales/en.json @@ -0,0 +1,47 @@ +{ + "texts": { + "current_marker": "current", + "new_session_started": "New session started.", + "memory_archival_failed_session": "Memory archival failed, session not cleared. Please try again.", + "memory_archival_failed_persona": "Memory archival failed, persona not switched. Please try again.", + "help_header": "🐈 nanobot commands:", + "cmd_new": "/new — Start a new conversation", + "cmd_lang_current": "/lang current — Show the active language", + "cmd_lang_list": "/lang list — List available languages", + "cmd_lang_set": "/lang set — Switch command language", + "cmd_persona_current": "/persona current — Show the active persona", + "cmd_persona_list": "/persona list — List available personas", + "cmd_persona_set": "/persona set — Switch persona and start a new session", + "cmd_stop": "/stop — Stop the current task", + "cmd_restart": "/restart — Restart the bot", + "cmd_help": "/help — Show available commands", + "current_persona": "Current persona: {persona}", + "available_personas": "Available personas:\n{items}", + "unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.", + "persona_already_active": "Persona {persona} is already active.", + "switched_persona": "Switched persona to {persona}. New session started.", + "current_language": "Current language: {language_name}", + "available_languages": "Available languages:\n{items}", + "unknown_language": "Unknown language: {name}\nAvailable languages: {languages}", + "language_already_active": "Language {language_name} is already active.", + "switched_language": "Language switched to {language_name}.", + "stopped_tasks": "Stopped {count} task(s).", + "no_active_task": "No active task to stop.", + "restarting": "Restarting...", + "generic_error": "Sorry, I encountered an error.", + "start_greeting": "Hi {name}. I'm nanobot.\n\nSend me a message and I'll respond.\nType /help to see available commands." + }, + "language_labels": { + "en": "English", + "zh": "Chinese" + }, + "telegram_commands": { + "start": "Start the bot", + "new": "Start a new conversation", + "lang": "Switch language", + "persona": "Show or switch personas", + "stop": "Stop the current task", + "help": "Show command help", + "restart": "Restart the bot" + } +} diff --git a/nanobot/locales/zh.json b/nanobot/locales/zh.json new file mode 100644 index 0000000..1621b86 --- /dev/null +++ b/nanobot/locales/zh.json @@ -0,0 +1,47 @@ +{ + "texts": { + "current_marker": "当前", + "new_session_started": "已开始新的会话。", + "memory_archival_failed_session": "记忆归档失败,会话未清空,请稍后重试。", + "memory_archival_failed_persona": "记忆归档失败,人格未切换,请稍后重试。", + "help_header": "🐈 nanobot 命令:", + "cmd_new": "/new — 开启新的对话", + "cmd_lang_current": "/lang current — 查看当前语言", + "cmd_lang_list": "/lang list — 查看可用语言", + "cmd_lang_set": "/lang set — 切换命令语言", + "cmd_persona_current": "/persona current — 查看当前人格", + "cmd_persona_list": "/persona list — 查看可用人格", + "cmd_persona_set": "/persona set — 切换人格并开始新会话", + "cmd_stop": "/stop — 停止当前任务", + "cmd_restart": "/restart — 重启机器人", + "cmd_help": "/help — 查看命令帮助", + "current_persona": "当前人格:{persona}", + "available_personas": "可用人格:\n{items}", + "unknown_persona": "未知人格:{name}\n可用人格:{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。", + "persona_already_active": "人格 {persona} 已经处于启用状态。", + "switched_persona": "已切换到人格 {persona},并开始新的会话。", + "current_language": "当前语言:{language_name}", + "available_languages": "可用语言:\n{items}", + "unknown_language": "未知语言:{name}\n可用语言:{languages}", + "language_already_active": "语言 {language_name} 已经处于启用状态。", + "switched_language": "已切换语言为 {language_name}。", + "stopped_tasks": "已停止 {count} 个任务。", + "no_active_task": "当前没有可停止的任务。", + "restarting": "正在重启……", + "generic_error": "抱歉,处理时遇到了错误。", + "start_greeting": "你好,{name}!我是 nanobot。\n\n给我发消息我就会回复你。\n输入 /help 查看可用命令。" + }, + "language_labels": { + "en": "英语", + "zh": "中文" + }, + "telegram_commands": { + "start": "启动机器人", + "new": "开启新对话", + "lang": "切换语言", + "persona": "查看或切换人格", + "stop": "停止当前任务", + "help": "查看命令帮助", + "restart": "重启机器人" + } +} diff --git a/pyproject.toml b/pyproject.toml index 5eb77c3..6b60365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ allow-direct-references = true [tool.hatch.build] include = [ "nanobot/**/*.py", + "nanobot/locales/**/*.json", "nanobot/templates/**/*.md", "nanobot/skills/**/*.md", "nanobot/skills/**/*.sh", diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 6eb4b4f..e974287 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -71,3 +71,29 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "Channel: cli" in user_content assert "Chat ID: direct" in user_content assert "Return exactly: OK" in user_content + + +def test_persona_prompt_uses_persona_overrides_and_memory(tmp_path: Path) -> None: + workspace = _make_workspace(tmp_path) + (workspace / "AGENTS.md").write_text("root agents", encoding="utf-8") + (workspace / "SOUL.md").write_text("root soul", encoding="utf-8") + (workspace / "USER.md").write_text("root user", encoding="utf-8") + (workspace / "memory").mkdir() + (workspace / "memory" / "MEMORY.md").write_text("root memory", encoding="utf-8") + + persona_dir = workspace / "personas" / "coder" + persona_dir.mkdir(parents=True) + (persona_dir / "SOUL.md").write_text("coder soul", encoding="utf-8") + (persona_dir / "USER.md").write_text("coder user", encoding="utf-8") + (persona_dir / "memory").mkdir() + (persona_dir / "memory" / "MEMORY.md").write_text("coder memory", encoding="utf-8") + + builder = ContextBuilder(workspace) + prompt = builder.build_system_prompt(persona="coder") + + assert "Current persona: coder" in prompt + assert "root agents" in prompt + assert "coder soul" in prompt + assert "coder user" in prompt + assert "coder memory" in prompt + assert "root memory" not in prompt diff --git a/tests/test_persona_commands.py b/tests/test_persona_commands.py new file mode 100644 index 0000000..bb2e047 --- /dev/null +++ b/tests/test_persona_commands.py @@ -0,0 +1,138 @@ +"""Tests for session-scoped persona switching.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.bus.events import InboundMessage + + +def _make_loop(workspace: Path, provider: MagicMock | None = None): + """Create an AgentLoop with a real workspace and lightweight mocks.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = provider or MagicMock() + provider.get_default_model.return_value = "test-model" + + with patch("nanobot.agent.loop.SubagentManager"): + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + return loop, provider + + +def _make_persona(workspace: Path, name: str, soul: str) -> None: + persona_dir = workspace / "personas" / name + persona_dir.mkdir(parents=True) + (persona_dir / "SOUL.md").write_text(soul, encoding="utf-8") + + +class TestPersonaCommands: + + @pytest.mark.asyncio + async def test_persona_switch_clears_session_and_persists_selection(self, tmp_path: Path) -> None: + _make_persona(tmp_path, "coder", "You are coder persona.") + loop, _provider = _make_loop(tmp_path) + loop.memory_consolidator.archive_unconsolidated = AsyncMock(return_value=True) + + session = loop.sessions.get_or_create("cli:direct") + session.add_message("user", "hello") + session.add_message("assistant", "hi") + loop.sessions.save(session) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/persona set coder") + ) + + assert response is not None + assert response.content == "Switched persona to coder. New session started." + loop.memory_consolidator.archive_unconsolidated.assert_awaited_once() + + switched = loop.sessions.get_or_create("cli:direct") + assert switched.metadata["persona"] == "coder" + assert switched.messages == [] + + current = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/persona current") + ) + listing = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/persona list") + ) + + assert current is not None + assert current.content == "Current persona: coder" + assert listing is not None + assert "- default" in listing.content + assert "- coder (current)" in listing.content + + @pytest.mark.asyncio + async def test_help_includes_persona_commands(self, tmp_path: Path) -> None: + loop, _provider = _make_loop(tmp_path) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help") + ) + + assert response is not None + assert "/persona current" in response.content + assert "/persona set " in response.content + + @pytest.mark.asyncio + async def test_language_switch_localizes_help(self, tmp_path: Path) -> None: + loop, _provider = _make_loop(tmp_path) + + switched = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/lang set zh") + ) + help_response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help") + ) + + assert switched is not None + assert "已切换语言为" in switched.content + assert help_response is not None + assert "/lang current — 查看当前语言" in help_response.content + assert "/persona current — 查看当前人格" in help_response.content + + @pytest.mark.asyncio + async def test_active_persona_changes_prompt_memory_scope(self, tmp_path: Path) -> None: + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock( + return_value=SimpleNamespace( + has_tool_calls=False, + content="ok", + finish_reason="stop", + reasoning_content=None, + thinking_blocks=None, + ) + ) + + (tmp_path / "SOUL.md").write_text("root soul", encoding="utf-8") + persona_dir = tmp_path / "personas" / "coder" + persona_dir.mkdir(parents=True) + (persona_dir / "SOUL.md").write_text("coder soul", encoding="utf-8") + (persona_dir / "memory").mkdir() + (persona_dir / "memory" / "MEMORY.md").write_text("coder memory", encoding="utf-8") + + loop, provider = _make_loop(tmp_path, provider) + session = loop.sessions.get_or_create("cli:direct") + session.metadata["persona"] = "coder" + loop.sessions.save(session) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="hello") + ) + + assert response is not None + assert response.content == "ok" + + messages = provider.chat_with_retry.await_args.kwargs["messages"] + assert "Current persona: coder" in messages[0]["content"] + assert "coder soul" in messages[0]["content"] + assert "coder memory" in messages[0]["content"] + assert "root soul" not in messages[0]["content"]