diff --git a/README.md b/README.md index b1714f6..9225a0b 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ We thus built Serena with the prospect of being able to cancel most other subscr "mcpServers": { "serena": { "command": "/abs/path/to/uv", - "args": ["run", "--directory", "/abs/path/to/serena", "serena", "/abs/path/to/myproject.yml"] + "args": ["run", "--directory", "/abs/path/to/serena", "serena-mcp-server", "/abs/path/to/myproject.yml"] } } } diff --git a/myproject.demo.yml b/myproject.demo.yml index 4ddcd76..b527229 100644 --- a/myproject.demo.yml +++ b/myproject.demo.yml @@ -4,7 +4,50 @@ project_root: /path/to/project language: python # list of directories to ignore: either names (e.g. "temp") or relative paths (e.g. "build/foo") ignored_dirs: [".git", "temp"] -# whether to open a graphical window with Serena's logs (not supported on MacOS) + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run serena-list-tools`. +# +# check_onboarding_performed +# create_text_file +# delete_lines +# delete_memory +# execute_shell_command +# find_referencing_symbols +# find_symbol +# get_dir_overview +# get_document_overview +# insert_after_symbol +# insert_at_line +# insert_before_symbol +# list_dir +# list_memories +# onboarding +# prepare_for_new_conversation +# read_file +# read_memory +# replace_symbol_body +# search_in_all_code +# summarize_changes +# think_about_collected_information +# think_about_task_adherence +# think_about_whether_you_are_done +# write_memory +excluded_tools: [] + + + +# Whether to open a graphical window with Serena's logs (not supported on MacOS). +# This is useful both for troubleshooting and for monitoring the tool calls, +# especially when using the agno playground, since the tool calls are not always shown, +# and the input params are never shown in the agno UI. +# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. +# Note: unfortunately, the various entities starting the Serena server or agent do so in +# mysterious ways, often starting multiple instances of the process without shutting down +# previous instances. This leads to multiple log windows being opened, and only the last +# window being updated. Since we can't control how agno or Claude Desktop starts Serena, we have to live with this limitation for now. gui_log_window: True # minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) gui_log_level: 20 diff --git a/pyproject.toml b/pyproject.toml index 55e13a5..e03e456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ ] [project.scripts] -serena = "serena.mcp:start_mcp_server" +serena-mcp-server = "serena.mcp:start_mcp_server" +serena-list-tools = "serena.agent:print_tool_overview" [project.license] text = "MIT" diff --git a/scripts/agno_agent.py b/scripts/agno_agent.py index a105a3f..eb318c2 100644 --- a/scripts/agno_agent.py +++ b/scripts/agno_agent.py @@ -1,19 +1,13 @@ import os from logging import Logger -from agno.agent.agent import Agent -from agno.memory.agent import AgentMemory from agno.models.anthropic.claude import Claude from agno.models.google.gemini import Gemini -from agno.playground.playground import Playground -from agno.playground.serve import serve_playground_app -from agno.storage.agent.sqlite import SqliteAgentStorage from dotenv import load_dotenv from sensai.util import logging from sensai.util.helper import mark_used from serena.agent import SerenaAgent -from serena.agno import SerenaAgnoToolkit mark_used(Gemini, Claude) @@ -36,27 +30,30 @@ os.makedirs(os.path.dirname(sql_db_path), exist_ok=True) if os.path.exists(sql_db_path): os.remove(sql_db_path) -model = Claude(id="claude-3-7-sonnet-20250219") -# model = Gemini(id="gemini-2.5-pro-exp-03-25") +for tool_name in serena_agent.list_tool_names(): + print(tool_name) -agno_agent = Agent( - name="Serena", - model=model, - storage=SqliteAgentStorage(table_name="serena_agent_sessions", db_file=sql_db_path), - description="A fully-featured coding assistant", - tools=[SerenaAgnoToolkit(serena_agent)], # type: ignore - show_tool_calls=False, - markdown=True, - system_message=serena_agent.prompt_factory.create_system_prompt(), - read_tool_call_history=False, - telemetry=False, - memory=AgentMemory(), - add_history_to_messages=True, - num_history_responses=100, # you might want to adjust this (expense vs. history awareness) -) +# model = Claude(id="claude-3-7-sonnet-20250219") +# # model = Gemini(id="gemini-2.5-pro-exp-03-25") -# The app object must be in the module scope so that the server can access it for hot reloading -app = Playground(agents=[agno_agent]).get_app() +# agno_agent = Agent( +# name="Serena", +# model=model, +# storage=SqliteAgentStorage(table_name="serena_agent_sessions", db_file=sql_db_path), +# description="A fully-featured coding assistant", +# tools=[SerenaAgnoToolkit(serena_agent)], # type: ignore +# show_tool_calls=False, +# markdown=True, +# system_message=serena_agent.prompt_factory.create_system_prompt(), +# read_tool_call_history=False, +# telemetry=False, +# memory=AgentMemory(), +# add_history_to_messages=True, +# num_history_responses=100, # you might want to adjust this (expense vs. history awareness) +# ) -if __name__ == "__main__": - serve_playground_app("agno_agent:app", reload=False) +# # The app object must be in the module scope so that the server can access it for hot reloading +# app = Playground(agents=[agno_agent]).get_app() + +# if __name__ == "__main__": +# serve_playground_app("agno_agent:app", reload=False) diff --git a/scripts/list_tools.py b/scripts/list_tools.py deleted file mode 100644 index 1dfafbd..0000000 --- a/scripts/list_tools.py +++ /dev/null @@ -1,10 +0,0 @@ -from serena.agent import SerenaAgent - -if __name__ == "__main__": - agent = SerenaAgent("myproject.yml") - tools = {} - for tool in agent.tools.values(): - tools[tool.get_name()] = tool - for tool_name in sorted(tools.keys()): - tool = tools[tool_name] - print(f" * `{tool_name}`: {tool.get_tool_description().strip()}") diff --git a/src/serena/agent.py b/src/serena/agent.py index 725b665..e4c8557 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,7 +11,7 @@ import traceback import warnings from abc import ABC from collections import defaultdict -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator, Iterable, Iterator from contextlib import contextmanager from logging import Logger from pathlib import Path @@ -29,6 +29,7 @@ from serena.gui_log_viewer import GuiLogViewer, GuiLogViewerHandler from serena.llm.prompt_factory import PromptFactory from serena.symbol import SymbolLocation, SymbolManager from serena.util.file_system import scan_directory +from serena.util.inspection import iter_subclasses from serena.util.shell import execute_shell_command log = logging.getLogger(__name__) @@ -87,11 +88,12 @@ class SerenaAgent: self.memories_manager = MemoriesManager(memories_dir) # find all tool classes and instantiate them + excluded_tools = project_config.get("excluded_tools", []) self.tools: dict[type[Tool], Tool] = {} - tool_classes = [ - cls for name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass) if issubclass(cls, Tool) and cls is not Tool - ] - for tool_class in tool_classes: + for tool_class in iter_tool_classes(): + if (tool_name := tool_class.get_name()) in excluded_tools: + log.info(f"Skipping tool {tool_name} because it is in the exclude list") + continue tool_instance = tool_class(self) self.tools[tool_class] = tool_instance log.info(f"Loaded tools: {', '.join([tool.get_name() for tool in self.tools.values()])}") @@ -101,11 +103,12 @@ class SerenaAgent: log.info("Starting the language server ...") self.language_server.start() - self._is_initialized = True - def get_tool(self, tool_class: type[TTool]) -> TTool: return self.tools[tool_class] # type: ignore + def print_tool_overview(self) -> None: + _print_tool_overview(self.tools.values()) + def get_serena_managed_dir(self) -> str: return os.path.join(self.project_root, ".serena") @@ -195,8 +198,9 @@ class Tool(Component): raise Exception(f"{self} does not define method apply") return apply_fn - def get_tool_description(self) -> str: - docstring = self.__class__.__doc__ + @classmethod + def get_tool_description(cls) -> str: + docstring = cls.__doc__ if docstring is None: return "" return docstring.strip() @@ -878,3 +882,20 @@ class ExecuteShellCommandTool(Tool): result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) result = result.json() return self._limit_length(result, max_answer_chars) + + +def iter_tool_classes() -> Generator[type[Tool], None, None]: + return iter_subclasses(Tool) + + +def print_tool_overview() -> None: + _print_tool_overview(iter_tool_classes()) + + +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()}") diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py new file mode 100644 index 0000000..36cffbe --- /dev/null +++ b/src/serena/util/inspection.py @@ -0,0 +1,12 @@ +from collections.abc import Generator +from typing import TypeVar + +T = TypeVar("T") + + +def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], None, None]: + """Iterate over all subclasses of a class. If recursive is True, also iterate over all subclasses of all subclasses.""" + for subclass in cls.__subclasses__(): + yield subclass + if recursive: + yield from iter_subclasses(subclass, recursive)