diff --git a/config/contexts/agent.yml b/config/contexts/agent.yml index 0f1d1bc5..d3da9da9 100644 --- a/config/contexts/agent.yml +++ b/config/contexts/agent.yml @@ -1,8 +1,6 @@ -name: agent description: All tools except InitialInstructionsTool for agent context -system_prompt: | +prompt: | You are running in agent context where the system prompt is provided externally. You should use symbolic tools when possible for code understanding and modification. excluded_tools: - - initial_instructions -tool_description_overrides: {} \ No newline at end of file + - initial_instructions \ No newline at end of file diff --git a/config/contexts/desktop-app.yml b/config/contexts/desktop-app.yml index 6f338068..ea1567fb 100644 --- a/config/contexts/desktop-app.yml +++ b/config/contexts/desktop-app.yml @@ -1,7 +1,13 @@ -name: desktop-app description: All tools included for desktop app context -system_prompt: | - You are running in desktop app context with all tools available. You should use the symbolic tools - when possible, but you also have access to file and shell operations for more complex tasks. -excluded_tools: [] -tool_description_overrides: {} \ No newline at end of file +prompt: | + You are running in desktop app context where the tools give you access to the code base as well as some + access to the file system, if configured. You interact with the user through a chat interface that is separated + from the code base. As a consequence, if you are in interactive mode, your communication with the user should + involve high-level thinking and planning as well as some summarization of any code edits that you make. + For viewing the code edits the user will view them in a separate code editor window, and the back-and-forth + between the chat and the code editor should be minimized as well as facilitated by you. + If complex changes have been made, advise the user on how to review them in the code editor. + If complex relationships that the user asked for should be visualized or explained, consider creating + a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering + options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions. +excluded_tools: [] \ No newline at end of file diff --git a/config/contexts/ide-assistant.yml b/config/contexts/ide-assistant.yml index 1a743714..230e9520 100644 --- a/config/contexts/ide-assistant.yml +++ b/config/contexts/ide-assistant.yml @@ -1,13 +1,12 @@ -name: ide-assistant description: Non-symbolic editing tools and general shell tool are excluded -system_prompt: | +prompt: | You are running in IDE assistant context where file operations and shell commands are handled by the IDE. You should exclusively use symbolic tools for exploring and modifying the code, as the IDE handles file-level operations. excluded_tools: - create_text_file + - read_file - delete_lines - replace_lines - insert_at_line - - execute_shell_command -tool_description_overrides: {} \ No newline at end of file + - execute_shell_command \ No newline at end of file diff --git a/config/modes/editing.yml b/config/modes/editing.yml index 0f5002e0..bddda04c 100644 --- a/config/modes/editing.yml +++ b/config/modes/editing.yml @@ -1,25 +1,5 @@ -name: editing description: All tools, with detailed instructions for code editing -system_prompt: | +prompt: | You are operating in editing mode. Your task is to implement the requested changes while adhering to the project's code style and patterns. Use symbolic editing tools whenever possible for precise code modifications. excluded_tools: [] -tool_description_overrides: - replace_symbol_body: | - Replaces the body of the symbol at the given location. This is a powerful tool for refactoring an entire class, method, - or function while maintaining its interface. - - Example: To refactor a method to improve performance or readability, first find the symbol using find_symbol, - then use this tool to replace the entire implementation with an improved version. - insert_after_symbol: | - Inserts the given body/content after the end of the definition of the given symbol. This is ideal for adding - new methods to a class, new functions to a module, or new fields to a class. - - Example: To add a new method to an existing class, first find the class using find_symbol, - then use this tool to insert the new method at the appropriate location. - insert_before_symbol: | - Inserts the given body/content before the beginning of the definition of the given symbol. This is useful - for adding imports, new classes, or documentation above an existing symbol. - - Example: To add missing imports at the top of a file, find the first symbol in the file using get_symbols_overview, - then use this tool to insert the imports before that symbol. \ No newline at end of file diff --git a/config/modes/interactive.yml b/config/modes/interactive.yml index bc1dd5dc..4da123d7 100644 --- a/config/modes/interactive.yml +++ b/config/modes/interactive.yml @@ -1,6 +1,5 @@ -name: interactive description: Interactive mode for clarification and step-by-step work -system_prompt: | +prompt: | You are operating in interactive mode. You should engage with the user throughout the task, asking for clarification whenever anything is unclear, insufficiently specified, or ambiguous. @@ -9,5 +8,4 @@ system_prompt: | Focus on providing informative results for intermediate steps so the user can follow along with your progress and provide feedback as needed. -excluded_tools: [] -tool_description_overrides: {} \ No newline at end of file +excluded_tools: [] \ No newline at end of file diff --git a/config/modes/onboarding.yml b/config/modes/onboarding.yml new file mode 100644 index 00000000..dff45403 --- /dev/null +++ b/config/modes/onboarding.yml @@ -0,0 +1,16 @@ +description: Only read-only tools, focused on analysis and planning +prompt: | + You are operating in onboarding mode. This is the first time you are seeing the project. + Your task is to collect relevant information about it and to save memories using the tools provided. + Call relevant onboarding tools for more instructions on how to do this. + In this mode, you should not be modifying any existing files. + If you are also in interactive mode and something about the project is unclear, ask the user for clarification. +excluded_tools: + - create_text_file + - replace_symbol_body + - insert_after_symbol + - insert_before_symbol + - delete_lines + - replace_lines + - insert_at_line + - execute_shell_command diff --git a/config/modes/one-shot.yml b/config/modes/one-shot.yml index 08732918..27d3eb66 100644 --- a/config/modes/one-shot.yml +++ b/config/modes/one-shot.yml @@ -1,6 +1,6 @@ -name: one-shot description: Focus on completely finishing a task without interaction -system_prompt: You are operating in one-shot mode. Your goal is to complete the entire task autonomously without further user interaction. +prompt: | + You are operating in one-shot mode. Your goal is to complete the entire task autonomously without further user interaction. You should assume auto-approval for all tools and continue working until the task is completely finished. If the task is planning, your final result should be a comprehensive plan. If the task is coding, your final result @@ -9,5 +9,7 @@ system_prompt: You are operating in one-shot mode. Your goal is to complete th Only abort the task if absolutely necessary, such as when critical information is missing that cannot be inferred from the codebase. -excluded_tools: [] -tool_description_overrides: {} \ No newline at end of file +excluded_tools: + - get_current_config + - activate_project + - activate_modes diff --git a/config/modes/planning.yml b/config/modes/planning.yml index 7a378337..84564ea1 100644 --- a/config/modes/planning.yml +++ b/config/modes/planning.yml @@ -1,6 +1,5 @@ -name: planning description: Only read-only tools, focused on analysis and planning -system_prompt: | +prompt: | You are operating in planning mode. Your task is to analyze code and create a comprehensive plan but not write any code. Focus on understanding the existing codebase structure, architecture, and functionality to create detailed planning documents that can be used for future implementation. @@ -13,17 +12,3 @@ excluded_tools: - replace_lines - insert_at_line - execute_shell_command -tool_description_overrides: - find_symbol: | - Retrieves information on all symbols/code entities, i.e. classes, methods, attributes, variables, etc. - with the given name. In planning mode, this tool is essential for understanding the codebase structure. - Use this tool to explore the codebase thoroughly before creating a comprehensive plan. - - Example: To understand class hierarchies, use this tool to find base classes, then follow up with find_referencing_symbols - to locate derived classes. - find_referencing_symbols: | - Finds symbols that reference the symbol at the given location. In planning mode, this tool is crucial for - understanding dependencies and relationships between different parts of the codebase. - - Example: To understand how a service class is used throughout the application, find the service class symbol first, - then use this tool to identify all consumers of the service. \ No newline at end of file diff --git a/config/prompt_templates/system_prompt.yml b/config/prompt_templates/system_prompt.yml index f6d1f856..344fc11e 100644 --- a/config/prompt_templates/system_prompt.yml +++ b/config/prompt_templates/system_prompt.yml @@ -8,6 +8,13 @@ prompts: files containing general information about the codebase. You operate in a frugal and intelligent manner, always keeping in mind to not read or generate content that is not needed for the task at hand. + The context and modes of operation are described below. From them you can infer how to interact with your user + and which tasks and kinds of interactions are expected of you. + + Context description: {{ context_system_prompt }} - {{ mode_system_prompt }} + Modes descriptions: + {% for prompt in mode_system_prompts %} + - {{ prompt }} + {% endfor %} diff --git a/pyproject.toml b/pyproject.toml index 6f4e41a1..f9cd4a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ [project.scripts] serena-mcp-server = "serena.mcp:start_mcp_server" -serena-list-tools = "serena.agent:print_tool_overview" [project.license] text = "GPL-2.0" diff --git a/scripts/print_mode_context_options.py b/scripts/print_mode_context_options.py new file mode 100644 index 00000000..20a24d79 --- /dev/null +++ b/scripts/print_mode_context_options.py @@ -0,0 +1,13 @@ +from serena.config import SerenaAgentContext, SerenaAgentMode + +if __name__ == "__main__": + print("---------- Available modes: ----------") + for mode_name in SerenaAgentMode.list_registered_mode_names(): + mode = SerenaAgentMode.load(mode_name) + mode.print_overview() + print("\n") + print("---------- Available contexts: ----------") + for context_name in SerenaAgentContext.list_registered_context_names(): + context = SerenaAgentContext.load(context_name) + context.print_overview() + print("\n") diff --git a/scripts/print_tool_overview.py b/scripts/print_tool_overview.py new file mode 100644 index 00000000..955ee78c --- /dev/null +++ b/scripts/print_tool_overview.py @@ -0,0 +1,4 @@ +from serena.agent import ToolRegistry + +if __name__ == "__main__": + ToolRegistry.print_tool_overview() diff --git a/src/serena/agent.py b/src/serena/agent.py index 87f25851..48c47cec 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,14 +11,11 @@ import traceback from abc import ABC from collections import defaultdict from collections.abc import Callable, Generator, Iterable +from copy import copy from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Self, TypeVar, Union, cast -# Import here so we have the type -if TYPE_CHECKING: - from serena.util.config_loader import ConfigData - import yaml from sensai.util import logging from sensai.util.logging import FallbackHandler @@ -29,6 +26,7 @@ from multilspy.multilspy_config import Language, MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_types import SymbolKind from serena import serena_root_path, serena_version +from serena.config import SerenaAgentContext, SerenaAgentMode from serena.prompt_factory import PromptFactory, SerenaPromptFactory from serena.symbol import SymbolLocation, SymbolManager from serena.text_utils import search_files @@ -122,6 +120,9 @@ class ProjectConfig(ToStringMixin): def get_serena_managed_dir(self) -> str: return os.path.join(self.project_root, self.SERENA_MANAGED_DIR) + def get_excluded_tool_classes(self) -> set[type["Tool"]]: + return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools) + @singleton class SerenaConfig: @@ -190,16 +191,18 @@ class SerenaAgent: self, project_file_path: str | None = None, project_activation_callback: Callable[[], None] | None = None, - context: str | None = None, - modes: list[str] | None = None, + context: SerenaAgentContext | None = None, + modes: list[SerenaAgentMode] | None = None, ): """ :param project_file_path: the configuration file (.yml) of the project to load immediately; if None, do not load any project (must use project selection tool to activate a project). If a project is provided, the corresponding language server will be started. :param project_activation_callback: a callback function to be called when a project is activated. - :param context: the context name or path to context file to use - :param modes: list of mode names or paths to mode files to use + :param context: the context in which the agent is operating, None for default context. + The context may adjust prompts, tool availability, and tool descriptions. + :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. + The modes may adjust prompts, tool availability, and tool descriptions. """ # obtain serena configuration self.serena_config = SerenaConfig() @@ -228,26 +231,6 @@ class SerenaAgent: self.prompt_factory = SerenaPromptFactory() self._project_activation_callback = project_activation_callback - # Load context and mode configuration - from serena.util.config_loader import ConfigData, ConfigLoader - - self.config_loader = ConfigLoader() - self.current_context: ConfigData | None = None - self.current_modes: list[ConfigData] = [] - - # Set context and modes if provided - if context is not None: - self.set_context(context) - else: - # Default to desktop-app context if none provided - self.set_context("desktop-app") - - if modes is not None: - self.set_modes(modes) - else: - # Default to interactive mode if none provided - self.set_modes(["interactive"]) - # project-specific instances, which will be initialized upon project activation self.project_config: ProjectConfig | None = None self.language_server: SyncLanguageServer | None = None @@ -257,7 +240,9 @@ class SerenaAgent: # find all tool classes and instantiate them self._all_tools: dict[type[Tool], Tool] = {} - for tool_class in iter_tool_classes(): + """maps tool classes to their instances (which are linked to the agent instance)""" + + for tool_class in ToolRegistry.get_all_tool_classes(): tool_instance = tool_class(self) if not self.serena_config.enable_project_activation: if tool_class in (GetActiveProjectTool, ActivateProjectTool): @@ -266,6 +251,12 @@ class SerenaAgent: self._all_tools[tool_class] = tool_instance # Apply context and mode tool configurations + if context is None: + context = SerenaAgentContext.load_default() + if modes is None: + modes = SerenaAgentMode.load_default_modes() + self.context = context + self.modes = modes self._update_active_tools() log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name() for tool in self._all_tools.values()])}") @@ -305,72 +296,23 @@ class SerenaAgent: # When project activation is not enabled, we only expose the active tools return list(self._active_tools.values()) - def set_context(self, context: str) -> None: - """ - Set the current context configuration. - - :param context: Name or path of the context to use - """ - context_config = self.config_loader.get_context(context) - self.current_context = context_config - - # Update tool configurations - self._update_active_tools() - - log.info(f"Set context to '{context_config.name}': {context_config.description}") - - def set_modes(self, modes: list[str]) -> None: + def set_modes(self, modes: list[SerenaAgentMode]) -> None: """ Set the current mode configurations. :param modes: List of mode names or paths to use """ - mode_configs = [self.config_loader.get_mode(mode) for mode in modes] - self._check_mode_conflicts(mode_configs) - self.current_modes = mode_configs + self.current_modes = modes self._update_active_tools() - log.info(f"Set modes to {[mode.name for mode in mode_configs]}") - - def _get_modes_system_prompt(self) -> str: - return "\n".join([mode.system_prompt_extension for mode in self.current_modes]) - - def _get_context_system_prompt(self) -> str: - return self.current_context.system_prompt_extension + log.info(f"Set modes to {[mode.name for mode in modes]}") def create_system_prompt(self) -> str: - context_system_prompt = self._get_context_system_prompt() - mode_system_prompt = self._get_modes_system_prompt() return self.prompt_factory.create_system_prompt( - context_system_prompt=context_system_prompt, - mode_system_prompt=mode_system_prompt, + context_system_prompt=self.context.prompt, + mode_system_prompts=[mode.prompt for mode in self.current_modes], ) - @staticmethod - def _check_mode_conflicts(mode_configs: list["ConfigData"]) -> None: - """ - Check for conflicts in tool exclusions between modes. - - :param mode_configs: List of mode configurations to check - :raises ValueError: If there are conflicts between modes - """ - if not mode_configs: - return - - # Check for conflicts in tool exclusions - tools_excluded_by_mode: dict[str, str] = {} - for mode in mode_configs: - for tool in mode.excluded_tools: - if tool in tools_excluded_by_mode: - # Another mode already excludes this tool - other_mode = tools_excluded_by_mode[tool] - if other_mode != mode.name: - raise ValueError( - f"Conflict between modes: Tool '{tool}' is excluded in mode '{other_mode}' but also in mode '{mode.name}'" - ) - else: - tools_excluded_by_mode[tool] = mode.name - def _update_active_tools(self) -> None: """ Update the active tools based on context, modes, and project configuration. @@ -380,47 +322,19 @@ class SerenaAgent: 2. Context exclusions 3. Mode exclusions """ - # Start with all tools - self._active_tools = dict(self._all_tools) - - # Collect all excluded tools - excluded_tools = set() - - # Apply mode exclusions + # Collect all excluded tools with the desired priority mode < context < project + excluded_tool_classes: set[type[Tool]] = set() for mode in self.current_modes: - excluded_tools.update(mode.excluded_tools) + excluded_tool_classes.update(mode.get_excluded_tool_classes()) + excluded_tool_classes.update(self.context.get_excluded_tool_classes()) + if self.project_config is not None: + excluded_tool_classes.update(self.project_config.get_excluded_tool_classes()) - # Apply context exclusions (overrides mode exclusions) - if self.current_context: - excluded_tools.update(self.current_context.excluded_tools) + self._active_tools = { + tool_class: tool_instance for tool_class, tool_instance in self._all_tools.items() if tool_class not in excluded_tool_classes + } - # Apply the exclusions - if excluded_tools: - self._active_tools = {key: tool for key, tool in self._active_tools.items() if tool.get_name() not in excluded_tools} - log.info(f"Tools excluded by context/mode: {sorted(excluded_tools)}") - - # Apply tool description overrides from context and modes - if self.current_context: - self._apply_tool_description_overrides(self.current_context.tool_description_overrides) - - for mode in self.current_modes: - self._apply_tool_description_overrides(mode.tool_description_overrides) - - log.info(f"Active tools after context/mode ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") - - def _apply_tool_description_overrides(self, overrides: dict[str, str]) -> None: - """ - Apply tool description overrides from context or mode configurations. - - :param overrides: Dictionary of tool name to description override - """ - for tool_name, description in overrides.items(): - for tool_class, tool in self._all_tools.items(): - if tool.get_name() == tool_name: - # Use monkey patching to override the docstring - # This is hacky but effective for this use case - tool.__class__.__doc__ = description - break + log.info(f"Active tools after all exclusions ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") def activate_project(self, project_config: ProjectConfig) -> None: log.info(f"Activating {project_config}") @@ -492,7 +406,7 @@ class SerenaAgent: return self._all_tools[tool_class] # type: ignore def print_tool_overview(self) -> None: - _print_tool_overview(self._active_tools.values()) + ToolRegistry.print_tool_overview(self._active_tools.values()) def mark_file_modified(self, relativ_path: str) -> None: assert self.lines_read is not None @@ -1365,26 +1279,6 @@ class PrepareForNewConversationTool(Tool): return self.prompt_factory.create_prepare_for_new_conversation() -class SetModesTool(Tool, ToolMarkerDoesNotRequireActiveProject): - """ - Changes the current operating modes of the agent. - """ - - def apply(self, modes: list[str]) -> str: - """ - Changes the current operating modes of the agent. - - :param modes: List of mode names to switch to (e.g. ["planning"], ["editing"], ["one-shot"], ["interactive"]), - or paths to custom mode configuration files - :return: Message indicating success or failure - """ - try: - self.agent.set_modes(modes) - return f"Successfully set modes to: {modes}" - except Exception as e: - return f"Failed to set modes: {e}" - - class SearchForPatternTool(Tool): """ Performs a search for a pattern in the project. @@ -1530,6 +1424,46 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): return SUCCESS_RESULT +class SwitchModesTool(Tool): + """ + Activates modes by providing a list of their names + """ + + def apply(self, modes: list[str]) -> str: + """ + Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"] + + :param modes: the names of the modes to activate + """ + mode_instances = [SerenaAgentMode.load(mode) for mode in modes] + self.agent.set_modes(mode_instances) + + # Inform the Agent about the activated modes and the currently active tools + result_str = f"Successfully activated modes: {', '.join([mode.name for mode in mode_instances])}" + result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}" + return result_str + + +class GetCurrentConfigTool(Tool): + """ + Prints the current configuration of the agent, including the active modes, tools, and context. + """ + + def apply(self) -> str: + """ + Print the current configuration of the agent, including the active modes, tools, and context. + """ + result_str = "Current configuration:\n" + if self.agent.project_config is not None: + result_str += f"Active project: {self.agent.project_config.project_name}\n" + result_str += f"Active context: {self.agent.context.name}\n" + result_str += "Active modes: {}\n".format(", ".join([mode.name for mode in self.agent.current_modes])) + result_str += "Active tools (exclusions from the project, context, and modes):\n" + result_str += "\n".join(self.agent.get_active_tool_names()) + return result_str + + class InitialInstructionsTool(Tool): """ Gets the initial instructions for the current project. @@ -1545,7 +1479,7 @@ class InitialInstructionsTool(Tool): return self.agent.create_system_prompt() -def iter_tool_classes(same_module_only: bool = True) -> Generator[type[Tool], None, None]: +def _iter_tool_classes(same_module_only: bool = True) -> Generator[type[Tool], None, None]: """ Iterate over Tool subclasses. @@ -1558,17 +1492,46 @@ def iter_tool_classes(same_module_only: bool = True) -> Generator[type[Tool], No yield tool_class -def print_tool_overview() -> None: - _print_tool_overview(iter_tool_classes()) +_TOOL_REGISTRY_DICT: dict[str, type[Tool]] = {tool_class.get_name(): tool_class for tool_class in _iter_tool_classes()} +"""maps tool name to the corresponding tool class""" -def _print_tool_overview(tools: Iterable[type[Tool] | Tool]) -> None: - tool_dict: dict[str, type[Tool] | Tool] = {} - for tool in tools: - tool_dict[tool.get_name()] = tool - for tool_name in sorted(tool_dict.keys()): - tool = tool_dict[tool_name] - print(f" * `{tool_name}`: {tool.get_tool_description().strip()}") +class ToolRegistry: + @staticmethod + def get_tool_class_by_name(tool_name: str) -> type[Tool]: + try: + return _TOOL_REGISTRY_DICT[tool_name] + except KeyError as e: + available_tools = "\n".join(ToolRegistry.get_tool_names()) + raise ValueError(f"Tool with name {tool_name} not found. Available tools:\n{available_tools}") from e + + @staticmethod + def get_all_tool_classes() -> list[type[Tool]]: + return list(_TOOL_REGISTRY_DICT.values()) + + @staticmethod + def get_tool_names() -> list[str]: + return list(_TOOL_REGISTRY_DICT.keys()) + + @staticmethod + def tool_dict() -> dict[str, type[Tool]]: + """Maps tool name to the corresponding tool class""" + return copy(_TOOL_REGISTRY_DICT) + + @staticmethod + def print_tool_overview(tools: Iterable[type[Tool] | Tool] | None = None) -> None: + """ + Print a summary of the tools. If no tools are passed, a summary of all tools is printed. + """ + if tools is None: + tools = _TOOL_REGISTRY_DICT.values() + + tool_dict: dict[str, type[Tool] | Tool] = {} + for tool_class in tools: + tool_dict[tool_class.get_name()] = tool_class + for tool_name in sorted(tool_dict.keys()): + tool_class = tool_dict[tool_name] + print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}") def _tuple_to_info(name: str, symbol_type: SymbolKind, line: int, column: int) -> dict[str, int | str]: diff --git a/src/serena/agno.py b/src/serena/agno.py index 3157229e..03acab76 100644 --- a/src/serena/agno.py +++ b/src/serena/agno.py @@ -117,7 +117,7 @@ class SerenaAgnoAgentProvider: # To see detailed logs, you should use the serena logger (configure it in the project file path) show_tool_calls=False, markdown=True, - system_message=serena_agent.prompt_factory.create_system_prompt(), + system_message=serena_agent.create_system_prompt(), telemetry=False, memory=AgentMemory(), add_history_to_messages=True, diff --git a/src/serena/config.py b/src/serena/config.py new file mode 100644 index 00000000..44de286c --- /dev/null +++ b/src/serena/config.py @@ -0,0 +1,140 @@ +""" +Context and Mode configuration loader +""" + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Self + +import yaml +from sensai.util import logging + +from serena.constants import CONTEXT_YAMLS_DIR, MODE_YAMLS_DIR + +if TYPE_CHECKING: + from serena.agent import Tool + +log = logging.getLogger(__name__) + + +@dataclass +class SerenaAgentMode: + """Represents a mode of operation for the agent, typically read off a YAML file. + An agent can be in multiple modes simultaneously as long as they are not mutually exclusive. + The modes can be adjusted after the agent is running, for example for switching from planning to editing. + """ + + name: str + prompt: str + description: str = "" + excluded_tools: set[str] = field(default_factory=set) + + def print_overview(self) -> None: + """Print an overview of the mode.""" + print(f"{self.name}:\n {self.description}") + if self.excluded_tools: + print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) + + def get_excluded_tool_classes(self) -> list[type["Tool"]]: + """Get the list of tool classes that are excluded from the mode.""" + from serena.agent import ToolRegistry + + return [ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools] + + @classmethod + def from_yaml(cls, yaml_path: str | Path) -> Self: + """Load a mode from a YAML file.""" + with open(yaml_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data.pop("name", Path(yaml_path).stem) + return cls(name=name, **data) + + @classmethod + def from_name(cls, name: str) -> Self: + """Load a registered Serena mode.""" + yaml_path = os.path.join(MODE_YAMLS_DIR, f"{name}.yml") + if not os.path.exists(yaml_path): + raise FileNotFoundError( + f"Mode {name} not found in {MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " + f"Available modes: {cls.list_registered_mode_names()}" + ) + return cls.from_yaml(yaml_path) + + @classmethod + def list_registered_mode_names(cls) -> list[str]: + """Names of all registered modes (from the corresponding YAML files in the serena repo).""" + return sorted([f.stem for f in Path(MODE_YAMLS_DIR).glob("*.yml")]) + + @classmethod + def load_default_modes(cls) -> list[Self]: + """Load the default modes (interactive and editing).""" + return [cls.from_name("interactive"), cls.from_name("editing")] + + @classmethod + def load(cls, name_or_path: str | Path) -> Self: + try: + return cls.from_name(str(name_or_path)) + except FileNotFoundError: + return cls.from_yaml(name_or_path) + + +@dataclass +class SerenaAgentContext: + """Represents a context where the agent is operating (an IDE, a chat, etc.), typically read off a YAML file. + An agent can only be in a single context at a time. + The contexts cannot be changed after the agent is running. + """ + + name: str + prompt: str + description: str = "" + excluded_tools: set[str] = field(default_factory=set) + + def get_excluded_tool_classes(self) -> list[type["Tool"]]: + """Get the list of tool classes that are excluded from the context.""" + from serena.agent import ToolRegistry + + return [ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools] + + @classmethod + def from_yaml(cls, yaml_path: str | Path) -> Self: + """Load a context from a YAML file.""" + with open(yaml_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data.pop("name", Path(yaml_path).stem) + return cls(name=name, **data) + + @classmethod + def from_name(cls, name: str) -> Self: + """Load a registered Serena context.""" + yaml_path = os.path.join(CONTEXT_YAMLS_DIR, f"{name}.yml") + if not os.path.exists(yaml_path): + raise FileNotFoundError( + f"Context {Path(yaml_path).stem} not found in {CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead. " + f"Available contexts: {cls.list_registered_context_names()}" + ) + return cls.from_yaml(yaml_path) + + @classmethod + def load(cls, name_or_path: str | Path) -> Self: + try: + return cls.from_name(str(name_or_path)) + except FileNotFoundError: + return cls.from_yaml(name_or_path) + + @classmethod + def list_registered_context_names(cls) -> list[str]: + """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" + return sorted([f.stem for f in Path(CONTEXT_YAMLS_DIR).glob("*.yml")]) + + @classmethod + def load_default(cls) -> Self: + """Load the default context.""" + return cls.from_name("default") + + def print_overview(self) -> None: + """Print an overview of the mode.""" + print(f"{self.name}:\n {self.description}") + if self.excluded_tools: + print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools))) diff --git a/src/serena/constants.py b/src/serena/constants.py index 1f456514..7ae62541 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -4,3 +4,5 @@ _repo_root_path = Path(__file__).parent.parent.parent REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR = str(_repo_root_path / "config" / "prompt_templates") +CONTEXT_YAMLS_DIR = str(_repo_root_path / "config" / "contexts") +MODE_YAMLS_DIR = str(_repo_root_path / "config" / "modes") diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 7f177118..52bd2195 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -31,5 +31,5 @@ class PromptFactory(PromptFactoryBase): def create_prepare_for_new_conversation(self) -> str: return self._render_prompt("prepare_for_new_conversation") - def create_system_prompt(self, *, context_system_prompt: Any, mode_system_prompt: Any) -> str: - return self._render_prompt("system_prompt", context_system_prompt=context_system_prompt, mode_system_prompt=mode_system_prompt) + def create_system_prompt(self, *, context_system_prompt: Any, mode_system_prompts: Any) -> str: + return self._render_prompt("system_prompt", context_system_prompt=context_system_prompt, mode_system_prompts=mode_system_prompts) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index e7880b28..8f5773c9 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -3,7 +3,7 @@ The Serena Model Context Protocol (MCP) Server """ import sys -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from logging import Formatter, Logger, StreamHandler @@ -19,6 +19,7 @@ from sensai.util import logging from sensai.util.helper import mark_used from serena.agent import SerenaAgent, Tool, show_fatal_exception_safe +from serena.config import SerenaAgentContext, SerenaAgentMode log = logging.getLogger(__name__) LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s" @@ -94,7 +95,7 @@ def make_tool( def create_mcp_server( - project_file_path: str | None, host: str = "0.0.0.0", port: int = 8000, context: str | None = None, modes: list[str] | None = None + project_file_path: str | None, host: str = "0.0.0.0", port: int = 8000, context: str = "default", modes: Sequence[str] = ("default",) ) -> FastMCP: """ Create an MCP server. @@ -106,14 +107,16 @@ def create_mcp_server( :param modes: List of mode names or paths to mode files """ mcp: FastMCP | None = None + context_instance = SerenaAgentContext.load(context) + modes_instances = [SerenaAgentMode.load(mode) for mode in modes] try: agent = SerenaAgent( project_file_path, # Callback disabled for the time being (see above) # project_activation_callback=update_tools - context=context, - modes=modes, + context=context_instance, + modes=modes_instances, ) except Exception as e: show_fatal_exception_safe(e) @@ -166,19 +169,18 @@ def create_mcp_server( @click.option( "--context", type=str, - default=None, + default="desktop-app", help="Context to use. This can be a name of a built-in context ('desktop-app', 'agent', 'ide-assistant') " - "or a path to a custom context YAML file. Defaults to 'desktop-app' if not specified.", + "or a path to a custom context YAML file.", ) @click.option( "--mode", "modes", type=str, multiple=True, - default=[], + default=["editing", "interactive"], help="Mode(s) to use. This can be names of built-in modes ('planning', 'editing', 'one-shot', 'interactive') " - "or paths to custom mode YAML files. Can be specified multiple times to combine modes. " - "Defaults to 'interactive' if not specified.", + "or paths to custom mode YAML files. Can be specified multiple times to combine modes.", ) @click.option( "--transport", @@ -204,11 +206,11 @@ def create_mcp_server( def start_mcp_server( project_file_opt: str | None, project_file_arg: str | None, - context: str | None, + context: str, modes: tuple[str, ...], - transport: Literal["stdio", "sse"], - host: str, - port: int, + transport: Literal["stdio", "sse"] = "stdio", + host: str = "0.0.0.0", + port: int = 8000, ) -> None: """Starts the Serena MCP server. @@ -220,10 +222,7 @@ def start_mcp_server( # This is for backward compatibility with the old CLI, should be removed in the future! project_file = project_file_arg if project_file_arg is not None else project_file_opt - # Convert modes tuple to list - modes_list = list(modes) if modes else None - - mcp_server = create_mcp_server(project_file_path=project_file, host=host, port=port, context=context, modes=modes_list) + mcp_server = create_mcp_server(project_file_path=project_file, host=host, port=port, context=context, modes=modes) # log after server creation such that the log appears in the GUI if project_file_arg is not None: diff --git a/src/serena/prompt_factory.py b/src/serena/prompt_factory.py index a14d34ba..f4f02ca7 100644 --- a/src/serena/prompt_factory.py +++ b/src/serena/prompt_factory.py @@ -7,5 +7,5 @@ class SerenaPromptFactory(PromptFactory): A class for retrieving and rendering prompt templates and prompt lists. """ - def __init__(self): + def __init__(self) -> None: super().__init__(PROMPT_TEMPLATES_DIR) diff --git a/src/serena/util/config_loader.py b/src/serena/util/config_loader.py deleted file mode 100644 index 7c096438..00000000 --- a/src/serena/util/config_loader.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Context and Mode configuration loader -""" - -import os -import pathlib -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import yaml -from sensai.util import logging - -log = logging.getLogger(__name__) - - -@dataclass -class ConfigData: - """Base class for context and mode configurations.""" - - name: str - description: str - system_prompt_extension: str - excluded_tools: set[str] - tool_description_overrides: dict[str, str] - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "ConfigData": - """Create a ConfigData instance from a dictionary.""" - return cls( - name=data.get("name", ""), - description=data.get("description", ""), - system_prompt_extension=data.get("system_prompt_extension", ""), - excluded_tools=set(data.get("excluded_tools", [])), - tool_description_overrides=data.get("tool_description_overrides", {}), - ) - - -class ConfigLoader: - """Handles loading of context and mode configurations.""" - - def __init__(self) -> None: - """Initialize the config loader.""" - self.serena_root = self._find_serena_root() - self.contexts_dir = os.path.join(self.serena_root, "prompts", "contexts") - self.modes_dir = os.path.join(self.serena_root, "prompts", "modes") - - # Ensure directories exist - os.makedirs(self.contexts_dir, exist_ok=True) - os.makedirs(self.modes_dir, exist_ok=True) - - # Cache loaded configs - self.context_cache: dict[str, ConfigData] = {} - self.mode_cache: dict[str, ConfigData] = {} - - def _find_serena_root(self) -> str: - """Find the root directory of Serena.""" - current_dir = pathlib.Path(__file__).parent.parent.parent.parent - return str(current_dir) - - def load_config_from_file(self, file_path: str | Path) -> ConfigData: - """Load a configuration from a file.""" - path = Path(file_path) - if not path.exists(): - raise FileNotFoundError(f"Config file not found: {file_path}") - - with open(path, encoding="utf-8") as f: - data = yaml.safe_load(f) - return ConfigData.from_dict(data) - - def get_context(self, context_name_or_path: str) -> ConfigData: - """ - Get the context configuration by name or path. - - :param context_name_or_path: Either a context name (will be looked up in contexts directory) - or a path to a YAML file - :return: The context configuration - """ - # Return from cache if available - if context_name_or_path in self.context_cache: - return self.context_cache[context_name_or_path] - - # Check if it's a file path - if os.path.isfile(context_name_or_path): - config = self.load_config_from_file(context_name_or_path) - self.context_cache[context_name_or_path] = config - return config - - # Check if it's a known context name - context_file = os.path.join(self.contexts_dir, f"{context_name_or_path}.yml") - if os.path.isfile(context_file): - config = self.load_config_from_file(context_file) - self.context_cache[context_name_or_path] = config - return config - - raise ValueError(f"Context not found: {context_name_or_path}. Please provide a valid context name or file path.") - - def get_mode(self, mode_name_or_path: str) -> ConfigData: - """ - Get the mode configuration by name or path. - - :param mode_name_or_path: Either a mode name (will be looked up in modes directory) - or a path to a YAML file - :return: The mode configuration - """ - # Return from cache if available - if mode_name_or_path in self.mode_cache: - return self.mode_cache[mode_name_or_path] - - # Check if it's a file path - if os.path.isfile(mode_name_or_path): - config = self.load_config_from_file(mode_name_or_path) - self.mode_cache[mode_name_or_path] = config - return config - - # Check if it's a known mode name - mode_file = os.path.join(self.modes_dir, f"{mode_name_or_path}.yml") - if os.path.isfile(mode_file): - config = self.load_config_from_file(mode_file) - self.mode_cache[mode_name_or_path] = config - return config - - raise ValueError(f"Mode not found: {mode_name_or_path}. Please provide a valid mode name or file path.") - - def list_available_contexts(self) -> list[str]: - """List all available context names.""" - return [f.stem for f in Path(self.contexts_dir).glob("*.yml")] - - def list_available_modes(self) -> list[str]: - """List all available mode names.""" - return [f.stem for f in Path(self.modes_dir).glob("*.yml")] diff --git a/test/serena/test_config_loader.py b/test/serena/test_config_loader.py deleted file mode 100644 index 658f4426..00000000 --- a/test/serena/test_config_loader.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Tests for the configuration loader for contexts and modes. -""" - -import os -import tempfile - -from serena.util.config_loader import ConfigData, ConfigLoader - - -def test_config_data_from_dict(): - """Test creating ConfigData from a dictionary.""" - data = { - "name": "test-context", - "description": "Test context", - "system_prompt_extension": "You are in test context", - "excluded_tools": ["tool1", "tool2"], - "tool_description_overrides": {"tool3": "Override desc"}, - } - - config = ConfigData.from_dict(data) - assert config.name == "test-context" - assert config.description == "Test context" - assert config.system_prompt_extension == "You are in test context" - assert config.excluded_tools == {"tool1", "tool2"} - assert config.tool_description_overrides == {"tool3": "Override desc"} - - -def test_config_loader_default_dirs(): - """Test that ConfigLoader initializes the default directories.""" - loader = ConfigLoader() - assert os.path.exists(loader.contexts_dir) - assert os.path.exists(loader.modes_dir) - - -def test_load_config_from_file(): - """Test loading a configuration from a file.""" - with tempfile.NamedTemporaryFile(suffix=".yml", mode="w+", delete=False) as f: - f.write( - """ -name: test-config -description: Test config from file -system_prompt_extension: Test from file -excluded_tools: - - tool1 - - tool2 -tool_description_overrides: - tool3: Override from file -""" - ) - f.flush() - - try: - loader = ConfigLoader() - config = loader.load_config_from_file(f.name) - - assert config.name == "test-config" - assert config.description == "Test config from file" - assert config.system_prompt_extension == "Test from file" - assert config.excluded_tools == {"tool1", "tool2"} - assert config.tool_description_overrides == {"tool3": "Override from file"} - finally: - os.unlink(f.name) - - -def test_get_context(): - """Test getting a context by name.""" - loader = ConfigLoader() - - # This assumes contexts/desktop-app.yml exists in the project - config = loader.get_context("desktop-app") - assert config.name == "desktop-app" - - # Test cache functionality - assert "desktop-app" in loader.context_cache - - -def test_get_mode(): - """Test getting a mode by name.""" - loader = ConfigLoader() - - # This assumes modes/interactive.yml exists in the project - config = loader.get_mode("interactive") - assert config.name == "interactive" - - # Test cache functionality - assert "interactive" in loader.mode_cache - - -def test_list_available(): - """Test listing available contexts and modes.""" - loader = ConfigLoader() - - contexts = loader.list_available_contexts() - assert len(contexts) > 0 - assert "desktop-app" in contexts - - modes = loader.list_available_modes() - assert len(modes) > 0 - assert "interactive" in modes diff --git a/test/serena/test_mcp.py b/test/serena/test_mcp.py index b1999174..f179b857 100644 --- a/test/serena/test_mcp.py +++ b/test/serena/test_mcp.py @@ -3,7 +3,7 @@ import pytest from mcp.server.fastmcp.tools.base import Tool as MCPTool -from serena.agent import Tool, iter_tool_classes +from serena.agent import Tool, ToolRegistry from serena.mcp import make_tool @@ -269,7 +269,7 @@ def is_test_mock_class(tool_class: type) -> bool: ) -@pytest.mark.parametrize("tool_class", list(iter_tool_classes())) +@pytest.mark.parametrize("tool_class", ToolRegistry.get_all_tool_classes()) def test_make_tool_all_tools(tool_class) -> None: """Test that make_tool works for all tools in the codebase."""