Proper first implementation of mode and context setting, WIP

This commit is contained in:
Michael Panchenko committed 2025-05-23 17:34:46 +02:00
1 parent 1c06dee660
commit 3584ce47eb
22 files changed
+347 -467

No files matched your search

+2 -4
View File
@@ -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: {}
- initial_instructions
+12 -6
View File
@@ -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: {}
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: []
+3 -4
View File
@@ -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: {}
- execute_shell_command
+1 -21
View File
@@ -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.
+2 -4
View File
@@ -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: {}
excluded_tools: []
+16
View File
@@ -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
+6 -4
View File
@@ -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: {}
excluded_tools:
- get_current_config
- activate_project
- activate_modes
+1 -16
View File
@@ -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.
+8 -1
View File
@@ -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 %}
-1
View File
@@ -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"
+13
View File
@@ -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")
+4
View File
@@ -0,0 +1,4 @@
from serena.agent import ToolRegistry
if __name__ == "__main__":
ToolRegistry.print_tool_overview()
+115 -152
View File
@@ -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]:
+1 -1
View File
@@ -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,
+140
View File
@@ -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)))
+2
View File
@@ -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")
@@ -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)
+16 -17
View File
@@ -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:
+1 -1
View File
@@ -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)
-131
View File
@@ -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")]
-100
View File
@@ -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
+2 -2
View File
@@ -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."""