Skip to content
Merged
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.

* General:
- Support `serena --version` CLI command for displaying the current version #1347
- Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350
- `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`)
- Fix: Duplicate comments in re-saved YAML configuration files #1285
Expand Down Expand Up @@ -31,6 +30,13 @@ Status of the `main` branch. Changes prior to the next official version change w
The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean.
- Add `serena_info` tool for on-demand retrieval of usage information

* CLI:
- Support `serena --version` CLI command for displaying the current version #1347
- Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`, improve `list` subcommand

* Clients:
- Document workaround to make Claude Code use Serena's tools after recent degradations caused by changes in CC harness and Opus 4.7 release.

* JetBrains:
- Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
Expand Down
28 changes: 26 additions & 2 deletions docs/02-usage/030_clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,32 @@ You can do this in Tools / GitHub Copilot / Chat, where at the bottom you can cl

## Claude Code

Serena is a great way to make Claude Code both cheaper and more powerful!
To add Serena to Claude Code, you can simply run `serena setup claude-code`. Alternatively, follow the instructions below.
Serena is a great way to make Claude Code both more efficient and more powerful!
To set up the Serena MCP server for Claude Code, you can simply run this command:

serena setup claude-code

Find manual setup instructions as well as workarounds for Claude Code's recent regressions pertaining to (external) tool use below.

:::{attention}
Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically reduced
adherence to instructions pertaining to Serena's tools.

After extensive analysis, we identified part of the reason to be very long and detailed
tool descriptions for built-in tools and parts of the default system prompt.
The descriptions of CC's system tools take almost 16k tokens, cannot be adjusted by the user,
and introduce a very strong bias towards internal tools, making it almost impossible to convince Opus 4.7 to use Serena.

As a workaround, we crafted a system prompt that counteracts this bias.
When using Serena, we highly recommend that you start CC as

```shell
claude --system-prompt="$(serena prompts print-cc-system-prompt-override)"
```

You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files,
but the effect be insufficient for counteracting Claude Code's bias towards internal tools.
:::

**Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag:

Expand Down
27 changes: 27 additions & 0 deletions news/20260427.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div class="news-item">
<h3>Serena v1.2.0</h3>
<p class="date">April 27, 2026</p>
<p>
<b>Interactive Debugging.</b>
With the <a target="_blank" href="https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html">Serena JetBrains plugin</a>,
your agent can set now set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger.
Our REPL-based interface is highly flexible, allowing all of this to be provided via a single tool.
</p>
<p>
<b>Counteracting Claude Code Regressions.</b>
We analysed the issues causing Claude Code to use external tools increasingly suboptimally and implemented
another workaround (in addition to the existing hooks): a system prompt override.
Read our updated <a target="_blank" href="https://oraios.github.io/serena/02-usage/030_clients.html#claude-code">Claude code usage instructions</a> for more information.
</p>
<p>
The update also adds JSON as a new supported "language" for the LSP backend,
improvements in token efficiency/prompt provision, as well as a multitude of general improvements and fixes.
See our <a target="_blank" href="https://github.com/oraios/serena/blob/main/CHANGELOG.md">change log</a> for details.<br>
</p>
<p>
<b>Update Now.</b>
If you are using the uv tool installation of Serena, upgrade as follows:<br>
uv tool upgrade serena-agent --prerelease=allow
</p>
</div>
4 changes: 4 additions & 0 deletions scripts/demo_cli_call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from serena.cli import top_level

if __name__ == "__main__":
top_level(["--help"])
3 changes: 3 additions & 0 deletions src/interprompt/jinja_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def __init__(self, template_string: str) -> None:
parsed_content = self._template.environment.parse(self._template_string)
self._parameters = sorted(jinja2.meta.find_undeclared_variables(parsed_content))

def get_template_string(self) -> str:
return self._template_string

def render(self, **params: Any) -> str:
"""Renders the template with the given kwargs. You can find out which parameters are required by calling get_parameter_names()."""
return self._template.render(**params)
Expand Down
11 changes: 8 additions & 3 deletions src/interprompt/multilang_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@


class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface):
def __init__(self, name: str, jinja_template_string: str) -> None:
def __init__(self, name: str, jinja_template_string: str, path: str) -> None:
self.name = name
self.path = path
self._jinja_template = JinjaTemplate(jinja_template_string.strip())

def _tostring_exclude_private(self) -> bool:
return True

def get_template_string(self) -> str:
return self._jinja_template.get_template_string()

def render(self, **params: Any) -> str:
return self._jinja_template.render(**params)

Expand Down Expand Up @@ -249,6 +253,7 @@ def _add_prompt_template(
self,
name: str,
template_str: str,
path: str,
lang_code: str = DEFAULT_LANG_CODE,
on_name_collision: Literal["skip", "overwrite", "raise"] = "raise",
) -> None:
Expand All @@ -259,7 +264,7 @@ def _add_prompt_template(
:param on_name_collision: how to deal with name/lang_code collisions
"""
allow_overwrite = False
prompt_template = PromptTemplate(name, template_str)
prompt_template = PromptTemplate(name, template_str, path=path)
mlpt = self._multi_lang_prompt_templates.get(name)
if mlpt is None:
mlpt = MultiLangPromptTemplate(name)
Expand Down Expand Up @@ -327,7 +332,7 @@ def _load_from_disc(self, prompts_dir: str, on_name_collision: Literal["skip", "
self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision)
elif isinstance(prompt_template_or_list, str):
self._add_prompt_template(
prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision
prompt_name, prompt_template_or_list, lang_code=lang_code, path=path, on_name_collision=on_name_collision
)
else:
raise ValueError(
Expand Down
20 changes: 15 additions & 5 deletions src/interprompt/prompt_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from typing import Any

from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList
from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList, PromptTemplate

log = logging.getLogger(__name__)

Expand All @@ -23,9 +23,18 @@ def __init__(self, prompts_dir: str | list[str], lang_code: str = DEFAULT_LANG_C
self.lang_code = lang_code
self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode)

def get_prompt_names(self) -> list[str]:
return self._prompt_collection.get_prompt_template_names()

def get_prompt_template(self, prompt_name: str) -> PromptTemplate:
return self._prompt_collection.get_prompt_template(prompt_name, lang_code=self.lang_code)

def get_prompt_template_string(self, prompt_name: str) -> str:
return self.get_prompt_template(prompt_name).get_template_string()

def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str:
del params["self"]
return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code)
return self.get_prompt_template(prompt_name).render(**params)

def _get_prompt_list(self, prompt_name: str) -> PromptList:
return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code)
Expand All @@ -41,14 +50,12 @@ def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str
:param prompts_dir: the directory containing the prompt templates and prompt lists
:param target_module_path: the path to the target module file (.py). Important: The module will be overwritten!
"""
generated_code = """
# ruff: noqa
generated_code = """# ruff: noqa
# black: skip
# mypy: ignore-errors

# NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually!

from interprompt.multilang_prompt import PromptList
from interprompt.prompt_factory import PromptFactoryBase
from typing import Any

Expand All @@ -68,6 +75,9 @@ class PromptFactory(PromptFactoryBase):
else:
method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters])
generated_code += f"""
def get_{template_name}_template_string(self) -> str:
return self.get_prompt_template_string('{template_name}')

def create_{template_name}(self{method_params_str}) -> str:
return self._render_prompt('{template_name}', locals())
"""
Expand Down
12 changes: 0 additions & 12 deletions src/serena/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,18 +869,6 @@ def get_active_project_or_raise(self) -> Project:
raise ValueError("No active project. Please activate a project first.")
return project

def set_modes(self, mode_names: list[str]) -> None:
"""
Set the current mode configurations.

:param mode_names: List of mode names or paths to use
"""
self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names)
self._update_active_modes()
self._update_active_tools()

log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}")

def get_active_modes(self) -> list[SerenaAgentMode]:
"""
:return: the list of active modes
Expand Down
66 changes: 58 additions & 8 deletions src/serena/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from tqdm import tqdm

from serena import serena_version
from serena.agent import SerenaAgent
from serena.config.client_setup import client_setup_handlers
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import (
Expand All @@ -36,9 +35,7 @@
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
)
from serena.mcp import SerenaMCPFactory
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry
from serena.prompt_factory import SerenaPromptFactory
from serena.util.cli_util import AutoRegisteringGroup
from serena.util.dataclass import get_dataclass_default
from serena.util.logging import MemoryLogHandler
Expand Down Expand Up @@ -315,6 +312,8 @@ def start_mcp_server(
trace_lsp_communication: bool | None,
tool_timeout: float | None,
) -> None:
from serena.mcp import SerenaMCPFactory

# initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config)
# * memory log handler (for use by GUI/Dashboard)
# * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop)
Expand Down Expand Up @@ -346,6 +345,7 @@ def start_mcp_server(
log.warning("No project root found from %s; not activating any project", os.getcwd())

project_file = project_file_arg or project

factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server(
host=host,
Expand Down Expand Up @@ -394,6 +394,8 @@ def start_mcp_server(
def print_system_prompt(
project: str, log_level: str, only_instructions: bool, context: str, modes: Sequence[str] | None = None
) -> None:
from serena.agent import SerenaAgent

prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account."
postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks."

Expand All @@ -403,9 +405,14 @@ def print_system_prompt(
modes_selection_def: ModeSelectionDefinition | None = None
if modes:
modes_selection_def = ModeSelectionDefinition(default_modes=modes)
serena_config = SerenaConfig.from_config_file()
serena_config.web_dashboard = False
print(serena_config.default_modes)
print(serena_config.base_modes)

agent = SerenaAgent(
project=os.path.abspath(project),
serena_config=SerenaConfig(web_dashboard=False, log_level=lvl),
serena_config=serena_config,
context=context_instance,
modes=modes_selection_def,
)
Expand Down Expand Up @@ -828,6 +835,8 @@ def is_ignored_path(path: str, project: str) -> None:
:param path: The path to check.
:param project: The path to the project directory, defaults to the current working directory.
"""
from serena.project import Project

serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
if os.path.isabs(path):
Expand All @@ -851,6 +860,8 @@ def index_file(file: str, project: str, verbose: bool) -> None:
:param project: path to the project directory, defaults to the current working directory.
:param verbose: if set, prints detailed information about the indexed symbols.
"""
from serena.project import Project

serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project), serena_config=serena_config)
if os.path.isabs(file):
Expand Down Expand Up @@ -887,6 +898,10 @@ def health_check(project: str) -> None:
:param project: path to the project directory, defaults to the current working directory.
"""
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
from serena.agent import SerenaAgent
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool

logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
serena_config = SerenaConfig.from_config_file()
Expand Down Expand Up @@ -1040,6 +1055,8 @@ def __init__(self) -> None:
@click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.")
@click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).")
def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None:
from serena.tools import ToolRegistry

tool_registry = ToolRegistry()
if quiet:
if only_optional:
Expand All @@ -1062,6 +1079,9 @@ def list(quiet: bool = False, include_optional: bool = False, only_optional: boo
@click.argument("tool_name", type=str)
@click.option("--context", type=str, default=None, help="Context name or path to context file.")
def description(tool_name: str, context: str | None = None) -> None:
from serena.agent import SerenaAgent
from serena.mcp import SerenaMCPFactory

# Load the context
serena_context = None
if context:
Expand Down Expand Up @@ -1089,16 +1109,26 @@ def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str:

@staticmethod
@click.command(
"list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
"list", help="Lists prompt names and YAML files that can be overridden.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
)
def list() -> None:
# list prompt names
click.echo("Prompts:")
factory = SerenaPromptFactory()
for key in factory.get_prompt_names():
template = factory.get_prompt_template(key)
is_overridden = not template.path.startswith(PROMPT_TEMPLATES_DIR_INTERNAL)
click.echo(f" * '{key}' ({template.path if is_overridden else 'default'})")

# list prompts files
click.echo("\nPrompt files (which you can override with the create-override command):")
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
for prompt_yaml_name in serena_prompt_yaml_names:
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
click.echo(f" * {user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
else:
click.echo(prompt_yaml_name)
click.echo(f" * {prompt_yaml_name}")

@staticmethod
@click.command(
Expand Down Expand Up @@ -1171,6 +1201,26 @@ def delete_override(prompt_yaml_name: str) -> None:
os.remove(user_prompt_yaml_path)
click.echo(f"Deleted override file '{prompt_yaml_name}'.")

@staticmethod
@click.command(
"print-prompt-template",
help="prints the (unrendered) template for the corresponding prompt name. "
"This respects custom prompt yaml overrides and thus will print the value that will be used in Serena",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
@click.argument("prompt_name", type=str)
def print_prompt_template(prompt_name: str) -> None:
click.echo(SerenaPromptFactory().get_prompt_template_string(prompt_name))

@staticmethod
@click.command(
"print-cc-system-prompt-override",
help="To be used specifically in Claude Code as value for `--system-prompt`",
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
)
def print_cc_system_prompt_override() -> None:
click.echo(SerenaPromptFactory().create_cc_system_prompt_override())


_mode = ModeCommands()
_context = ContextCommands()
Expand Down
Loading
Loading