Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 62 additions & 8 deletions scripts/demo_run_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,79 @@
for testing or development. Here the tools will be operation the serena repo itself.
"""

from serena.agent import SerenaAgent
from serena.analytics import RegisteredTokenCountEstimator
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.constants import REPO_ROOT
from serena.tools import (
ExecuteSerenaCodeTool,
FindFileTool,
FindReferencingSymbolsTool,
GetSymbolsOverviewTool,
JetBrainsFindSymbolTool,
SearchForPatternTool,
)

# Find usages of `get_context` method of all classes with `Agent` in their name
# TODO:
# 1. We shouldn't have to do `if isinstance(..., str): json.loads(...)`
# 2. Overview doesn't have a clear interface - sometimes values are strings (if no children present), sometimes dicts
# That's not necessarily wrong (it saves tokens) but for this particular application it makes parsing more cumbersome.
# Maybe rethink?
# 3. For some reason we get `You passed a file explicitly, but it is ignored. This is probably an error. File: src\serena\agent.py`
# The code still works, I don't understand why this warning is triggered.
# 4. See todo in cmd_tools.py (about switching the backend to LSP)
demo_code = """
import json
from pprint import pprint
from pathlib import Path

from serena.agent import SerenaAgent
from serena.constants import REPO_ROOT
from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, JetBrainsFindSymbolTool, SearchForPatternTool
matches = serena_agent.apply_tool("search_for_pattern", substring_pattern="class .*?Agent", restrict_search_to_code_files=True)
if isinstance(matches, str):
matches = json.loads(matches)

candidate_files = list(matches)
# contains tuples of (file_path, name_path) for all run methods found
get_context_methods = []

for file_path in candidate_files:
symbols_overview = serena_agent.apply_tool("get_symbols_overview", relative_path=file_path, depth=1)
if isinstance(symbols_overview, str):
symbols_overview = json.loads(symbols_overview)
for cls_overview in symbols_overview.get("Class", []):
if isinstance(cls_overview, str):
continue # no members
if isinstance(cls_overview, str):
cls_overview = json.loads(cls_overview)
cls_name, children = list(cls_overview.items())[0] # has only one key which is the symbol name
methods = children.get("Method", [])
if "get_context" in methods:
get_context_methods.append((str(Path(file_path)), cls_name+"/get_context"))

print(get_context_methods)
for file_path, name_path in get_context_methods:
usages = serena_agent.apply_tool("find_referencing_symbols", name_path=name_path, relative_path=file_path)
print(f"Usages of {name_path} in {file_path}:")
pprint(usages)
"""

if __name__ == "__main__":
agent = SerenaAgent(project=REPO_ROOT)
serena_config = SerenaConfig.from_config_file()
serena_config.language_backend = LanguageBackend.LSP
serena_config.web_dashboard = False
serena_config.token_count_estimator = RegisteredTokenCountEstimator.CHAR_COUNT.name
serena_config.included_optional_tools = ["execute_serena_code"]
agent = SerenaAgent(project=REPO_ROOT, serena_config=serena_config)

# apply a tool
find_symbol_tool = agent.get_tool(JetBrainsFindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
find_file_tool = agent.get_tool(FindFileTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
execute_code_tool = agent.get_tool(ExecuteSerenaCodeTool)

result = agent.execute_task(
lambda: find_symbol_tool.apply("displayBasicStats"),
)
pprint(json.loads(result))
result = agent.execute_task(lambda: execute_code_tool.apply(demo_code))
print(result)
# pprint(json.loads(result))
# input("Press Enter to continue...")
17 changes: 16 additions & 1 deletion src/serena/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import webbrowser
from collections.abc import Callable
from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar
from typing import TYPE_CHECKING, Any, Optional, TypeVar

from sensai.util import logging
from sensai.util.logging import LogTime
Expand Down Expand Up @@ -335,6 +335,21 @@ def get_context(self) -> SerenaAgentContext:
def get_tool_description_override(self, tool_name: str) -> str | None:
return self._context.tool_description_overrides.get(tool_name, None)

def apply_tool(self, tool: type[Tool] | str, **input_kwargs: Any) -> str | dict:
"""
Run a tool with the given input arguments.

:param tool: the tool class or tool name to run
:param input_kwargs: the input arguments for the tool
:return: the result of the tool execution
"""
if isinstance(tool, str):
tool_class = ToolRegistry().get_tool_class_by_name(tool)
else:
tool_class = tool
tool_instance = self.get_tool(tool_class)
return tool_instance.apply_ex(**input_kwargs)

def _check_shell_settings(self) -> None:
# On Windows, Claude Code sets COMSPEC to Git-Bash (often even with a path containing spaces),
# which causes all sorts of trouble, preventing language servers from being launched correctly.
Expand Down
90 changes: 89 additions & 1 deletion src/serena/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@
)
from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry
from serena.tools import (
ExecuteSerenaCodeTool,
FindReferencingSymbolsTool,
FindSymbolTool,
GetSymbolsOverviewTool,
SearchForPatternTool,
ToolRegistry,
)
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import Language
from solidlsp.util.subprocess_util import subprocess_kwargs
Expand Down Expand Up @@ -301,6 +308,87 @@ def print_system_prompt(project: str, log_level: str, only_instructions: bool, c
else:
print(f"{prefix}\n{instr}\n{postfix}")

@staticmethod
@click.command("execute-code", help="Execute Python code with access to Serena tools.")
@click.option(
"--project",
type=click.Path(exists=True),
default=None,
help="Path to the project directory. If not provided, uses current directory or attempts auto-detection.",
)
@click.option(
"--code",
"-c",
type=str,
default=None,
help="Python code to execute. If not provided, reads from stdin.",
)
@click.option(
"--file",
"-f",
type=click.Path(exists=True),
default=None,
help="Path to a Python file to execute.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for code execution.",
)
def execute_code(project: str | None, code: str | None, file: str | None, log_level: str) -> None:
"""
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docstring not adjusted yet, will be done after we finalize the example

Execute Python code with access to Serena's tools and print the captured stdout.
The code can be provided via the --code option, the --file option, or piped via stdin.

This command allows you to run Python code that uses Serena's agent and tools
programmatically without needing to set up an MCP server.

Examples:
# Execute code from command line
serena execute-code -c "from serena.agent import SerenaAgent; print('hello')"

# Execute code from a file
serena execute-code -f my_script.py

# Execute code from stdin
echo "print('hello')" | serena execute-code

# Specify a project
serena execute-code --project /path/to/project -c "..."

:param project: Path to the project directory. If not provided, uses current directory or attempts auto-detection.
:param code: Python code to execute as string.
:param file: Path to a Python file to execute.
:param log_level: Log level for code execution.

"""
# Set up logging
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)

# Determine code source
if sum([code is not None, file is not None, not sys.stdin.isatty()]) > 1:
raise click.UsageError("Provide exactly one of: --code, --file, or stdin")

if code is None and file is None and sys.stdin.isatty():
raise click.UsageError("No code provided. Use --code, --file, or pipe to stdin")

# Read code
if code is not None:
code_to_execute = code
elif file is not None:
with open(file) as f:
code_to_execute = f.read()
else:
code_to_execute = sys.stdin.read()

if project is None:
project = find_project_root()

result = ExecuteSerenaCodeTool.execute_code(code_to_execute, project_path=project, log_level=lvl)
click.echo(result)


class ModeCommands(AutoRegisteringGroup):
"""Group for 'mode' subcommands."""
Expand Down
116 changes: 115 additions & 1 deletion src/serena/tools/cmd_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
Tools supporting the execution of (external) commands
"""

import contextlib
import io
import logging
import os.path

from serena.tools import Tool, ToolMarkerCanEdit
from serena.tools import Tool, ToolMarkerCanEdit, ToolMarkerOptional
from serena.util.shell import execute_shell_command


Expand Down Expand Up @@ -50,3 +53,114 @@ def apply(
result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr)
result = result.json()
return self._limit_length(result, max_answer_chars)


class ExecuteSerenaCodeTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional):
"""
Executes python code in an environment where Serena is installed.
"""

# TODO: `serena_config.language_backend = LanguageBackend.LSP` is needed until we include the depth parameter
# into the jetbrains overview tool. This is now just to make the example in demo_run_tools.py work.
# Of course, this becomes very slow since we start the LS every time.
# We can also consider making the serena_agent variable available in a different way, maybe by passing it
# as a parameter to exec instead of creating it inside the executed code.
_AGENT_INSTANCE_PREPEND_CODE_TEMPLATE = """\
from serena.agent import SerenaAgent
from serena.analytics import RegisteredTokenCountEstimator
from serena.config.serena_config import SerenaConfig, LanguageBackend

serena_config = SerenaConfig.from_config_file()
serena_config.language_backend = LanguageBackend.LSP
serena_config.log_level = {log_level}
serena_config.web_dashboard = False
serena_config.token_count_estimator = RegisteredTokenCountEstimator.CHAR_COUNT.name
optional_tools = serena_config.included_optional_tools or []
if "execute_serena_code" not in optional_tools:
optional_tools.append("execute_serena_code")
serena_config.included_optional_tools = optional_tools
serena_agent = SerenaAgent(project={project_path!r}, serena_config=serena_config)
"""

@classmethod
def execute_code(cls, code: str, project_path: str | None = None, log_level: int = logging.WARNING) -> str:
"""
Execute python code in an environment where Serena is installed and return the captured stdout.

:param code: the python code to execute
:param project_path: the path to the project to use for the serena_agent instance.
If None, uses the current working directory.
:param log_level: the logging level to set during code execution
:return: the stdout output of the executed code
"""
# We can make it parametrizable later if needed
prepend_agent_instance = True

stdout_buffer = io.StringIO()
if prepend_agent_instance:
if project_path is None:
project_path = os.getcwd()
prepend_code = cls._AGENT_INSTANCE_PREPEND_CODE_TEMPLATE.format(project_path=project_path, log_level=log_level)
code = prepend_code + "\n" + code
try:
with contextlib.redirect_stdout(stdout_buffer):
exec(code)
output = stdout_buffer.getvalue().strip().rstrip("\n")
if not output:
output = "Code executed successfully, no output."
except Exception as e:
output = f"Error executing code: {e}"
return output

def apply(
self,
code: str,
max_answer_chars: int | None = None,
project_path: str | None = None,
) -> str:
"""
Execute python code in an environment where Serena is installed and return the captured stdout.
An instance of SerenaAgent called 'serena_agent' is available for use in the code, all tools are accessible
through it. This tool is useful for advanced usage of tools where scripting gives a benefit (e.g., looping,
filtering, combining multiple tool calls, etc.).

Example - find usages of `run` method of all classes that contain `Agent` in their name (passed as triple-quoted
string as the `code` parameter):

.. code-block:: python

matches = serena_agent.apply_tool("search_for_pattern", substring_pattern="class .*Agent.*", restrict_search_to_code_files=True)
if isinstance(matches, str):
import json
matches = json.loads(matches)
candidate_files = list(matches)
# contains tuples of (file_path, name_path) for all run methods found
run_methods = []

for file_path in candidate_files:
symbols_overview = serena_agent.apply_tool("get_symbol_overview", relative_path=file_path, depth=1)
cls_overviews = symbols_overview["Class"]
for cls_overview in cls_overviews:
cls_name = list(cls_overview)[0] # has only one key which is the symbol name
methods = cls_overview[cls_name].get("Methods", [])
if "run" in methods:
run_methods.append((file_path, cls_name+"/run"))

from pprint import pprint
for file_path, name_path in run_methods:
usages = serena_agent.apply_tool("find_referencing_symbols", name_path=name_path, relative_path=file_path)
print(f"Usages of {name_path} in {file_path}:")
pprint(usages)

:param code: the python code to execute
:param max_answer_chars: limit the length of the returned output to this number of characters (if provided,
-1 means using the default value)
:param project_path: the path to the project to use for the serena_agent instance.
If None, uses the current agent's project root.
:return: the stdout output of the executed code
"""
result = self.execute_code(
code,
project_path=project_path or self.get_project_root(),
)
return self._limit_length(result, max_answer_chars)
4 changes: 3 additions & 1 deletion src/serena/tools/tools_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ def _log_tool_application(self, frame: Any) -> None:
params[param] = value
log.info(f"{self.get_name_from_cls()}: {dict_string(params)}")

def _limit_length(self, result: str, max_answer_chars: int) -> str:
def _limit_length(self, result: str, max_answer_chars: int | None) -> str:
if max_answer_chars is None:
return result
if max_answer_chars == -1:
max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars
if max_answer_chars <= 0:
Expand Down
Loading