From 64ef986d75583c23dd2c90f2700ead656ff7d29d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 18 Apr 2026 12:29:35 +0200 Subject: [PATCH 1/6] Refactor ActiveModes, improving the caching --- src/serena/agent.py | 48 +++++++++++++++++---------------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 30ef7cc5..cf3e9c25 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -197,52 +197,40 @@ class ToolSet: class ActiveModes: + _mode_instances: dict[str, SerenaAgentMode] = {} + def __init__(self) -> None: - self._base_modes: Sequence[str] | None = None - self._default_modes: Sequence[str] | None = None - self._active_mode_names: Sequence[str] | None = [] - self._active_modes: Sequence[SerenaAgentMode] | None = [] + self._configured_base_modes: Sequence[str] | None = None + self._configured_default_modes: Sequence[str] | None = None + self._active_mode_names: Sequence[str] = [] def apply(self, mode_selection: ModeSelectionDefinition) -> None: - # invalidate active modes - self._active_mode_names = None - self._active_modes = None - # apply overrides log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes) if mode_selection.base_modes is not None: - self._base_modes = mode_selection.base_modes + self._configured_base_modes = mode_selection.base_modes if mode_selection.default_modes is not None: - self._default_modes = mode_selection.default_modes - log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._base_modes, self._default_modes) + self._configured_default_modes = mode_selection.default_modes + log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._configured_base_modes, self._configured_default_modes) + + self._active_mode_names = sorted(set(self._configured_base_modes or []) | set(self._configured_default_modes or [])) def get_mode_names(self) -> Sequence[str]: - if self._active_mode_names is not None: - return self._active_mode_names - active_mode_names: set[str] = set() - if self._base_modes is not None: - active_mode_names.update(self._base_modes) - if self._default_modes is not None: - active_mode_names.update(self._default_modes) - self._active_mode_names = sorted(active_mode_names) - log.info("Active modes: %s", self._active_mode_names) return self._active_mode_names + def _get_mode_instance(self, mode_name: str): + if mode_name not in self._mode_instances: + self._mode_instances[mode_name] = SerenaAgentMode.load(mode_name) + return self._mode_instances[mode_name] + def get_modes(self) -> Sequence[SerenaAgentMode]: - if self._active_modes is not None: - return self._active_modes - self._active_modes = [] - for mode_name in self.get_mode_names(): - mode = SerenaAgentMode.load(mode_name) - self._active_modes.append(mode) - return self._active_modes + return [self._get_mode_instance(mode_name) for mode_name in self._active_mode_names] - # TODO: apply caching like in get_modes def get_default_modes(self) -> Sequence[SerenaAgentMode]: - return [SerenaAgentMode.load(mode_name) for mode_name in self._default_modes or []] + return [self._get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []] def get_base_modes(self) -> Sequence[SerenaAgentMode]: - return [SerenaAgentMode.load(mode_name) for mode_name in self._base_modes or []] + return [self._get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] class SerenaAgent: From 9bc815b3e182fe4aa1c5247c4b1088d82c684d2f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 18 Apr 2026 13:13:18 +0200 Subject: [PATCH 2/6] Ensure that prompts for dynamically activated modes are appropriately transmitted Fixes #1370 --- CHANGELOG.md | 2 + src/serena/agent.py | 99 +++++++++++++++++++++++++++---- src/serena/config/context_mode.py | 6 ++ 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03f1f3..653ad549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Status of the `main` branch. Changes prior to the next official version change w - 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 + - Fix: Prompts of dynamically activated modes (upon project activation) were not necessary passed to the LLM (only in the system prompt via + `initial_instructions`). Now they are passed directly in the activation message (and excluded from a subsequent `initial_instructions` call). * JetBrains: - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) diff --git a/src/serena/agent.py b/src/serena/agent.py index cf3e9c25..76388b7b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -218,19 +218,63 @@ class ActiveModes: def get_mode_names(self) -> Sequence[str]: return self._active_mode_names - def _get_mode_instance(self, mode_name: str): - if mode_name not in self._mode_instances: - self._mode_instances[mode_name] = SerenaAgentMode.load(mode_name) - return self._mode_instances[mode_name] + @classmethod + def get_mode_instance(cls, mode_name: str) -> SerenaAgentMode: + if mode_name not in cls._mode_instances: + cls._mode_instances[mode_name] = SerenaAgentMode.load(mode_name) + return cls._mode_instances[mode_name] def get_modes(self) -> Sequence[SerenaAgentMode]: - return [self._get_mode_instance(mode_name) for mode_name in self._active_mode_names] + return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names] def get_default_modes(self) -> Sequence[SerenaAgentMode]: - return [self._get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []] + return [self.get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []] def get_base_modes(self) -> Sequence[SerenaAgentMode]: - return [self._get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] + return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] + + +class DynamicModeProvisionStatus: + """ + Manages the provision of modes that have been newly activated (by dynamic project activation) + """ + + def __init__(self, newly_activated_mode_names: set[str]): + """ + :param newly_activated_mode_names: list of mode names that have been newly activated (by dynamic project activation) + and for which prompts must still be provided (either in the system prompt or the activation prompt) + """ + self._newly_activated_mode_names: set[str] = newly_activated_mode_names + self._prompts_provided = False + + def is_prompt_already_provided(self, mode_name: str) -> bool: + """ + :param mode_name: the mode name + :return: whether the mode name was already provided (in a project-specific activation message) and therefore + should not be included again + """ + if not self._prompts_provided: + return False + return mode_name in self._newly_activated_mode_names + + def get_modes_with_prompts_to_be_provided_for_project_activation(self) -> list[SerenaAgentMode]: + """ + Gets the modes that have been newly activated and for which prompts still need to be provided + (in dynamic project activation message). + """ + result = [] + if not self._prompts_provided: + for mode_name in self._newly_activated_mode_names: + mode = ActiveModes.get_mode_instance(mode_name) + if mode.has_prompt(): + result.append(mode) + return result + + def mark_mode_prompts_as_provided(self) -> None: + """ + Marks the prompts for all newly activated modes as provided, so that they will not be included in the project activation message. + """ + self._prompts_provided = True class SerenaAgent: @@ -258,7 +302,8 @@ class SerenaAgent: self._active_project: Project | None = None self._gui_log_viewer: Optional["GuiLogViewer"] = None self._dashboard_viewer_process: multiprocessing.Process | None = None - + self._dynamic_mode_provision_status = DynamicModeProvisionStatus(set()) + self._mode_overrides = modes self.version = serena_version() # obtain serena configuration using the decoupled factory function @@ -267,6 +312,10 @@ class SerenaAgent: # propagate configuration to other components self.serena_config.propagate_settings() + # initialise active modes (baseline modes prior to project activation) + self._active_modes: ActiveModes + self._update_active_modes(log_message=False) + # determine registered project to be activated (if any) registered_project_to_activate: RegisteredProject | None = ( self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None @@ -357,8 +406,6 @@ class SerenaAgent: # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet # (and its computation depends on the active project) - self._active_modes: ActiveModes - self._mode_overrides = modes if project is not None: try: self.activate_project_from_path_or_name(project, update_active_modes=False, update_active_tools=False) @@ -682,9 +729,19 @@ class SerenaAgent: ).list_global_memories() global_memories_str = dict_string(global_memories.to_dict()) if len(global_memories) > 0 else "" log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers) + + # determine modes for which prompts must (still) be provided, excluding modes that were already provided in a + # previously provided project activation message (if any) + relevant_modes = [] + for mode in self.get_active_modes(): + if mode.has_prompt(): + if not self._dynamic_mode_provision_status.is_prompt_already_provided(mode.name): + relevant_modes.append(mode) + self._dynamic_mode_provision_status.mark_mode_prompts_as_provided() + system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), - mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()], + mode_system_prompts=[self._format_prompt(mode.prompt) for mode in relevant_modes], available_tools=available_tools.tool_names, available_markers=available_markers, global_memories_list=global_memories_str, @@ -721,11 +778,21 @@ class SerenaAgent: f"\n{json.dumps(project_memories.to_dict())}\n" + "Use the `read_memory` tool to read these memories later if they are relevant to the task." ) + + # add prompts for modes that were dynamically activated by the project + modes_with_prompts = self._dynamic_mode_provision_status.get_modes_with_prompts_to_be_provided_for_project_activation() + if modes_with_prompts: + msg += "\nNewly applicable mode instructions:" + for mode in modes_with_prompts: + msg += f"\n{mode.prompt}" + self._dynamic_mode_provision_status.mark_mode_prompts_as_provided() + if proj.project_config.initial_prompt: - msg += f"\nAdditional project-specific instructions:\n {proj.project_config.initial_prompt}" + msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}" + return msg - def _update_active_modes(self) -> None: + def _update_active_modes(self, log_message: bool = True) -> None: """ Updates the active modes based on the Serena configuration, the active project configuration (if any), and mode overrides (if any). @@ -736,6 +803,9 @@ class SerenaAgent: self._active_modes.apply(self._active_project.project_config) if self._mode_overrides: self._active_modes.apply(self._mode_overrides) + if log_message: + active_mode_names = self._active_modes.get_mode_names() + log.info(f"Active modes ({len(active_mode_names)}): {', '.join(active_mode_names)}") def _update_active_tools(self) -> None: """ @@ -827,7 +897,10 @@ class SerenaAgent: project.set_agent(self) if update_active_modes: + active_mode_names_before = set(self._active_modes.get_mode_names()) self._update_active_modes() + newly_activated_mode_names = set(self._active_modes.get_mode_names()) - active_mode_names_before + self._dynamic_mode_provision_status = DynamicModeProvisionStatus(newly_activated_mode_names=newly_activated_mode_names) if update_active_tools: self._update_active_tools() diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 053f54ee..bb3164c9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -136,6 +136,12 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): return cls.from_name(str(name_or_path)) + def has_prompt(self) -> bool: + """ + :return: whether this mode defines a prompt + """ + return bool(self.prompt and self.prompt.strip()) + @dataclass(kw_only=True) class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): From 269fe26d084775799a78ece2d712e5742acecb25 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 19 Apr 2026 14:00:19 +0200 Subject: [PATCH 3/6] Prevent project activation message from being provided more than once Fixes #1372 --- CHANGELOG.md | 2 ++ src/serena/agent.py | 56 ++++++++++++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653ad549..442c1b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Duplicate comments in re-saved YAML configuration files #1285 - Fix: Prompts of dynamically activated modes (upon project activation) were not necessary passed to the LLM (only in the system prompt via `initial_instructions`). Now they are passed directly in the activation message (and excluded from a subsequent `initial_instructions` call). + - Fix: Project activation message was provided more than once for case of dynamic project activation followed + by `initial_instructions` #1372 * JetBrains: - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) diff --git a/src/serena/agent.py b/src/serena/agent.py index 76388b7b..b0d8d883 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -234,26 +234,29 @@ class ActiveModes: return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] -class DynamicModeProvisionStatus: +class ProjectPromptProvisionStatus: """ - Manages the provision of modes that have been newly activated (by dynamic project activation) + Manages the status of the provision of project-specific prompts """ - def __init__(self, newly_activated_mode_names: set[str]): + def __init__(self, newly_activated_mode_names: set[str] | None = None): """ :param newly_activated_mode_names: list of mode names that have been newly activated (by dynamic project activation) - and for which prompts must still be provided (either in the system prompt or the activation prompt) + and for which prompts must still be provided (either in the system prompt or via the activation message) """ + if newly_activated_mode_names is None: + newly_activated_mode_names = set() self._newly_activated_mode_names: set[str] = newly_activated_mode_names - self._prompts_provided = False + self._mode_prompts_provided = False + self._project_activation_message_provided = False - def is_prompt_already_provided(self, mode_name: str) -> bool: + def is_mode_prompt_already_provided(self, mode_name: str) -> bool: """ :param mode_name: the mode name :return: whether the mode name was already provided (in a project-specific activation message) and therefore - should not be included again + should not be included again (in the Serena instructions manual) """ - if not self._prompts_provided: + if not self._mode_prompts_provided: return False return mode_name in self._newly_activated_mode_names @@ -263,7 +266,7 @@ class DynamicModeProvisionStatus: (in dynamic project activation message). """ result = [] - if not self._prompts_provided: + if not self._mode_prompts_provided: for mode_name in self._newly_activated_mode_names: mode = ActiveModes.get_mode_instance(mode_name) if mode.has_prompt(): @@ -274,7 +277,19 @@ class DynamicModeProvisionStatus: """ Marks the prompts for all newly activated modes as provided, so that they will not be included in the project activation message. """ - self._prompts_provided = True + self._mode_prompts_provided = True + + def mark_project_activation_message_as_provided(self) -> None: + """ + Marks the project activation message as provided, so that it will not be included again in case of multiple activations of the same project. + """ + self._project_activation_message_provided = True + + def is_project_activation_message_already_provided(self) -> bool: + """ + :return: whether the project activation message was already provided and therefore should not be included again + """ + return self._project_activation_message_provided class SerenaAgent: @@ -302,7 +317,7 @@ class SerenaAgent: self._active_project: Project | None = None self._gui_log_viewer: Optional["GuiLogViewer"] = None self._dashboard_viewer_process: multiprocessing.Process | None = None - self._dynamic_mode_provision_status = DynamicModeProvisionStatus(set()) + self._project_prompt_status = ProjectPromptProvisionStatus() self._mode_overrides = modes self.version = serena_version() @@ -735,9 +750,9 @@ class SerenaAgent: relevant_modes = [] for mode in self.get_active_modes(): if mode.has_prompt(): - if not self._dynamic_mode_provision_status.is_prompt_already_provided(mode.name): + if not self._project_prompt_status.is_mode_prompt_already_provided(mode.name): relevant_modes.append(mode) - self._dynamic_mode_provision_status.mark_mode_prompts_as_provided() + self._project_prompt_status.mark_mode_prompts_as_provided() system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), @@ -747,8 +762,8 @@ class SerenaAgent: global_memories_list=global_memories_str, ) - # If a project is active at startup, append its activation message - if self._active_project is not None: + # provide the project activation message if it hasn't yet been provided + if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(): system_prompt += "\n\n" + self.get_project_activation_message() log.info("System prompt:\n%s", system_prompt) @@ -780,16 +795,18 @@ class SerenaAgent: ) # add prompts for modes that were dynamically activated by the project - modes_with_prompts = self._dynamic_mode_provision_status.get_modes_with_prompts_to_be_provided_for_project_activation() + modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation() if modes_with_prompts: msg += "\nNewly applicable mode instructions:" for mode in modes_with_prompts: msg += f"\n{mode.prompt}" - self._dynamic_mode_provision_status.mark_mode_prompts_as_provided() + self._project_prompt_status.mark_mode_prompts_as_provided() if proj.project_config.initial_prompt: msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}" + self._project_prompt_status.mark_project_activation_message_as_provided() + return msg def _update_active_modes(self, log_message: bool = True) -> None: @@ -900,7 +917,10 @@ class SerenaAgent: active_mode_names_before = set(self._active_modes.get_mode_names()) self._update_active_modes() newly_activated_mode_names = set(self._active_modes.get_mode_names()) - active_mode_names_before - self._dynamic_mode_provision_status = DynamicModeProvisionStatus(newly_activated_mode_names=newly_activated_mode_names) + else: + newly_activated_mode_names = None + + self._project_prompt_status = ProjectPromptProvisionStatus(newly_activated_mode_names=newly_activated_mode_names) if update_active_tools: self._update_active_tools() From b3c92d32d868c357ec77e6264ea5b9428d29dc27 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 19 Apr 2026 22:39:25 +0200 Subject: [PATCH 4/6] Support session-aware tools: Tools can now use a session_id: str parameter in apply * Not included in client-relevant interface * Injected via apply_ex if apply defines the parameter (apply should never document the parameter, notably) --- src/serena/tools/tools_base.py | 45 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 7a453ae0..c13e4cd4 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -3,6 +3,7 @@ import json from abc import ABC from collections.abc import Callable, Iterable from dataclasses import dataclass +from functools import cached_property from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast @@ -134,9 +135,32 @@ class Tool(Component): # (which is use by the LLM, so a good description is important) # and to validate the tool call arguments. + SESSION_ID_PARAM_NAME = "session_id" + """ + parameter name to use in apply method for the client session ID. + This parameter will be ignored by the MCP interface but will be populated with the session ID of the current client session + when the tool is called, allowing tools to be session-aware if needed. + """ + _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" + def __init__(self, agent: "SerenaAgent"): + super().__init__(agent) + + @cached_property + def _is_session_aware(self) -> bool: + """ + :return: whether the tool is session-aware, i.e. whether the apply method expects a session_id (str) parameter. + """ + # check apply method for session_id arg + apply_fn = self.get_apply_fn() + sig = inspect.signature(apply_fn) + for param in sig.parameters.values(): + if param.name == self.SESSION_ID_PARAM_NAME: + return True + return False + @staticmethod def _sanitize_input_param(raw_param: str) -> str: # some clients replace < and > with their escaped html versions, we need to counteract this @@ -225,9 +249,9 @@ class Tool(Component): if apply_fn is None: raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") - return func_metadata(apply_fn, skip_names=["self", "cls"]) + return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME]) - def _log_tool_application(self, frame: Any) -> None: + def _log_tool_application(self, frame: Any, session_id: str) -> None: params = {} ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"} for param, value in frame.f_locals.items(): @@ -237,7 +261,7 @@ class Tool(Component): params.update(value) else: params[param] = value - log.info(f"{self.get_name_from_cls()}: {dict_string(params)}") + log.info(f"{self.get_name_from_cls()}: {dict_string(params)}; session_id: {session_id}") def _limit_length( self, @@ -284,8 +308,10 @@ class Tool(Component): """ Applies the tool with logging and exception handling, using the given keyword arguments """ + session_id = "global" if mcp_ctx is not None: try: + session_id = "%x" % id(mcp_ctx.session) client_params = mcp_ctx.session.client_params if client_params is not None: client_info = cast(Implementation, client_params.clientInfo) @@ -306,7 +332,7 @@ class Tool(Component): return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" if log_call: - self._log_tool_application(inspect.currentframe()) + self._log_tool_application(inspect.currentframe(), session_id) try: # check whether the tool requires an active project and language server if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): @@ -316,9 +342,14 @@ class Tool(Component): + f"{self.agent.serena_config.project_names}" ) + # construct apply kwargs, adding session_id if the tool is session-aware + apply_kwargs = dict(kwargs) + if self._is_session_aware: + apply_kwargs["session_id"] = session_id + # apply the actual tool try: - result = apply_fn(**kwargs) + result = apply_fn(**apply_kwargs) except SolidLSPException as e: if e.is_language_server_terminated(): affected_language = e.get_affected_language() @@ -327,7 +358,7 @@ class Tool(Component): f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." ) self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) - result = apply_fn(**kwargs) + result = apply_fn(**apply_kwargs) else: log.error( f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." @@ -337,7 +368,7 @@ class Tool(Component): raise # record tool usage - self.agent.record_tool_usage(kwargs, result, self) + self.agent.record_tool_usage(apply_kwargs, result, self) except Exception as e: if not catch_exceptions: From 3d315121075f209e73f2ca8a9c3fb710f8416bd4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 19 Apr 2026 22:42:10 +0200 Subject: [PATCH 5/6] Manage project-specific prompt provision per session (MCP client session ID) This allows the stateful handling introduced for #1370 #1372 to correctly apply to HTTP mode as well, by adjusting tools to be session-aware: * InitialInstructionsTool * ActivateProjectTool --- CHANGELOG.md | 11 +++-- src/serena/agent.py | 76 +++++++++++++++++++++--------- src/serena/cli.py | 4 +- src/serena/tools/config_tools.py | 12 +++-- src/serena/tools/workflow_tools.py | 6 ++- 5 files changed, 72 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 442c1b66..928f916c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ Status of the `main` branch. Changes prior to the next official version change w - 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 - - Fix: Prompts of dynamically activated modes (upon project activation) were not necessary passed to the LLM (only in the system prompt via - `initial_instructions`). Now they are passed directly in the activation message (and excluded from a subsequent `initial_instructions` call). - - Fix: Project activation message was provided more than once for case of dynamic project activation followed - by `initial_instructions` #1372 + - Prompt provision improvements (project activation, initial instructions): + - Prompt provision is now session-aware, i.e. when using the MCP server in HTTP mode, prompts are provided for each session separately, + ensuring that the necessary information is always provided to the LLM + - Fix: Prompts of dynamically activated modes (upon project activation) were not necessarily passed to the LLM (only in the system prompt via + `initial_instructions`). Now they are passed directly in the activation message (and excluded from a subsequent `initial_instructions` call). + - Fix: Project activation message was provided more than once for case of dynamic project activation followed + by `initial_instructions` #1372 * JetBrains: - `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors) diff --git a/src/serena/agent.py b/src/serena/agent.py index b0d8d883..ddeb4307 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -9,8 +9,10 @@ import platform import signal import subprocess import sys +from collections import defaultdict from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager +from dataclasses import dataclass from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -239,6 +241,11 @@ class ProjectPromptProvisionStatus: Manages the status of the provision of project-specific prompts """ + @dataclass + class SessionStatus: + mode_prompts_provided: bool = False + project_activation_message_provided: bool = False + def __init__(self, newly_activated_mode_names: set[str] | None = None): """ :param newly_activated_mode_names: list of mode names that have been newly activated (by dynamic project activation) @@ -247,49 +254,60 @@ class ProjectPromptProvisionStatus: if newly_activated_mode_names is None: newly_activated_mode_names = set() self._newly_activated_mode_names: set[str] = newly_activated_mode_names - self._mode_prompts_provided = False - self._project_activation_message_provided = False + self._session_status_dict: dict[str, ProjectPromptProvisionStatus.SessionStatus] = defaultdict(lambda: self.SessionStatus()) - def is_mode_prompt_already_provided(self, mode_name: str) -> bool: + def _get_session_status(self, session_id: str) -> SessionStatus: + return self._session_status_dict[session_id] + + def is_mode_prompt_already_provided(self, mode_name: str, session_id: str) -> bool: """ :param mode_name: the mode name + :param session_id: the client session ID :return: whether the mode name was already provided (in a project-specific activation message) and therefore should not be included again (in the Serena instructions manual) """ - if not self._mode_prompts_provided: + if not self._get_session_status(session_id).mode_prompts_provided: return False return mode_name in self._newly_activated_mode_names - def get_modes_with_prompts_to_be_provided_for_project_activation(self) -> list[SerenaAgentMode]: + def get_modes_with_prompts_to_be_provided_for_project_activation(self, session_id: str) -> list[SerenaAgentMode]: """ Gets the modes that have been newly activated and for which prompts still need to be provided (in dynamic project activation message). + + :param: session_id: the client session ID + :return: the modes """ result = [] - if not self._mode_prompts_provided: + if not self._get_session_status(session_id).mode_prompts_provided: for mode_name in self._newly_activated_mode_names: mode = ActiveModes.get_mode_instance(mode_name) if mode.has_prompt(): result.append(mode) return result - def mark_mode_prompts_as_provided(self) -> None: + def mark_mode_prompts_as_provided(self, session_id: str) -> None: """ Marks the prompts for all newly activated modes as provided, so that they will not be included in the project activation message. - """ - self._mode_prompts_provided = True - def mark_project_activation_message_as_provided(self) -> None: + :param session_id: the client session ID + """ + self._get_session_status(session_id).mode_prompts_provided = True + + def mark_project_activation_message_as_provided(self, session_id: str) -> None: """ Marks the project activation message as provided, so that it will not be included again in case of multiple activations of the same project. - """ - self._project_activation_message_provided = True - def is_project_activation_message_already_provided(self) -> bool: + :param session_id: the client session ID """ + self._get_session_status(session_id).project_activation_message_provided = True + + def is_project_activation_message_already_provided(self, session_id: str) -> bool: + """ + :param session_id: the client session ID :return: whether the project activation message was already provided and therefore should not be included again """ - return self._project_activation_message_provided + return self._get_session_status(session_id).project_activation_message_provided class SerenaAgent: @@ -736,7 +754,11 @@ class SerenaAgent: template = JinjaTemplate(prompt_template) return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) - def create_system_prompt(self) -> str: + def create_system_prompt(self, session_id: str = "global") -> str: + """ + :param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case + :return: the prompt + """ available_tools = self._active_tools available_markers = available_tools.tool_marker_names global_memories = MemoriesManager( @@ -750,9 +772,9 @@ class SerenaAgent: relevant_modes = [] for mode in self.get_active_modes(): if mode.has_prompt(): - if not self._project_prompt_status.is_mode_prompt_already_provided(mode.name): + if not self._project_prompt_status.is_mode_prompt_already_provided(mode.name, session_id): relevant_modes.append(mode) - self._project_prompt_status.mark_mode_prompts_as_provided() + self._project_prompt_status.mark_mode_prompts_as_provided(session_id) system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), @@ -763,19 +785,25 @@ class SerenaAgent: ) # provide the project activation message if it hasn't yet been provided - if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(): - system_prompt += "\n\n" + self.get_project_activation_message() + if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id): + system_prompt += "\n\n" + self.get_project_activation_message(session_id) log.info("System prompt:\n%s", system_prompt) return system_prompt - def get_project_activation_message(self) -> str: + def get_project_activation_message(self, session_id: str) -> str: """ :return: a message providing information about the project upon activation (e.g. programming language, memories, initial prompt) :raise: AssertionError if no project is active """ proj = self._active_project assert proj is not None, "A project must be active before calling this." + + # if activation message was already provided in the current session, don't provide it again + if self._project_prompt_status.is_project_activation_message_already_provided(session_id): + return "Project was already activated." + + # provide basic project information (name, location, languages, encoding) if proj.is_newly_created: msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}. " else: @@ -785,6 +813,7 @@ class SerenaAgent: msg += f"\nProgramming languages: {languages_str}." msg += f"File encoding: {proj.project_config.encoding}." + # add list of memories (if memories are enabled) include_memories = self._active_tools.contains_tool_class(ReadMemoryTool) if include_memories: project_memories = proj.memories_manager.list_project_memories() @@ -795,17 +824,18 @@ class SerenaAgent: ) # add prompts for modes that were dynamically activated by the project - modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation() + modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id) if modes_with_prompts: msg += "\nNewly applicable mode instructions:" for mode in modes_with_prompts: msg += f"\n{mode.prompt}" - self._project_prompt_status.mark_mode_prompts_as_provided() + self._project_prompt_status.mark_mode_prompts_as_provided(session_id) + # add project-specific prompt if proj.project_config.initial_prompt: msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}" - self._project_prompt_status.mark_project_activation_message_as_provided() + self._project_prompt_status.mark_project_activation_message_as_provided(session_id) return msg diff --git a/src/serena/cli.py b/src/serena/cli.py index a82ba180..eb8e91fc 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -396,7 +396,6 @@ class TopLevelCommands(AutoRegisteringGroup): ) -> None: 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." - from serena.tools.workflow_tools import InitialInstructionsTool lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) @@ -410,8 +409,7 @@ class TopLevelCommands(AutoRegisteringGroup): context=context_instance, modes=modes_selection_def, ) - tool = agent.get_tool(InitialInstructionsTool) - instr = tool.apply() + instr = agent.create_system_prompt() if only_instructions: print(instr) else: diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 2008012b..12e4baf0 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -1,3 +1,5 @@ +from sensai.util.helper import mark_used + from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional @@ -22,17 +24,17 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): Activates a project based on the project name or path. """ - def apply(self, project: str) -> str: + # noinspection PyIncorrectDocstring + # (session_id is injected via apply_ex) + def apply(self, project: str, session_id: str) -> str: """ Activates the project with the given name or path. :param project: the name of a registered project to activate or a path to a project directory """ is_new_activation = self.agent.activate_project_from_path_or_name(project) - if not is_new_activation: - result = "Project was already active." - else: - result = self.agent.get_project_activation_message() + mark_used(is_new_activation) + result = self.agent.get_project_activation_message(session_id) result += "\nIMPORTANT: If you have not yet read the 'Serena Instructions Manual', do it now before continuing!" return result diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index 7b1f5d96..e50ec9b5 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -63,10 +63,12 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject): for clients that do not read the initial instructions when the MCP server is connected. """ - def apply(self) -> str: + # noinspection PyIncorrectDocstring + # (session_id is injected via apply_ex) + def apply(self, session_id: str) -> str: """ Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox. IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user, as it will critically inform you! """ - return self.agent.create_system_prompt() + return self.agent.create_system_prompt(session_id=session_id) From 3fea7b562e905452dda52b1f3fca9c755b9ad2f9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 20 Apr 2026 13:29:39 +0200 Subject: [PATCH 6/6] Add TestPromptProvision --- test/serena/test_serena_agent.py | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index be078eae..c529fa2e 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -14,11 +14,14 @@ from serena.config.serena_config import ProjectConfig, RegisteredProject, Serena from serena.project import Project from serena.tools import ( SUCCESS_RESULT, + ActivateProjectTool, FindReferencingSymbolsTool, FindSymbolTool, + InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, SafeDeleteSymbol, + Tool, ) from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @@ -697,3 +700,81 @@ class TestSerenaAgent: assert name_path not in file_content, ( f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content" ) + + +class TestPromptProvision: + class MockContext: + def __init__(self, session_id: str): + self.session = session_id + + @classmethod + def _call_tool(cls, agent: SerenaAgent, tool_class: type[Tool], session_id: str = "global", **kwargs) -> str: + result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) # type: ignore + return result + + @staticmethod + def _assert_activation_message(result: str, project_name: str, present: bool) -> None: + regex = r"^The project with name '" + project_name + r"'.*?is activated.$" + match = re.search(regex, result, re.MULTILINE) + if present: + assert match is not None, f"Expected project activation message in result:\n{result}" + else: + assert match is None, f"Expected no project activation message in result:\n{result}" + + @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True) + def test_initial_instructions_provide_project_activation_message_once_per_session(self, serena_agent: SerenaAgent) -> None: + """ + Tests that the project activation message is provided on the first call to InitialInstructionsTool for a session, + but not on subsequent calls within the same session. #1372 + """ + project_name = "test_repo_python" + + result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session1") + self._assert_activation_message(result1, project_name, present=True) + + result2 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session2") + self._assert_activation_message(result2, project_name, present=True) + + result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session1") + self._assert_activation_message(result3, project_name, present=False) + + @pytest.mark.parametrize("serena_agent", [Language.PYTHON], indirect=True) + def test_dynamically_activated_mode_is_provided_once_per_session(self, serena_agent: SerenaAgent) -> None: + """ + Tests that when a new project is activated within a session that has a different mode configuration (e.g. no-onboarding), + the new mode's prompts are provided at project activation but not in subsequent initial instructions calls within the same + session, while they are provided in the initial instructions of a new session. + """ + project_name1 = "test_repo_python" + project_name2 = "test_repo_java" + + # the initial instructions must contain the project activation message for the first project + result1 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session1") + self._assert_activation_message(result1, project_name1, present=True) + + # now activate another project which dynamically enables a new mode (no-onboarding) + reg_project = serena_agent.serena_config.get_registered_project(project_name2) + reg_project.project_config.default_modes = ["no-onboarding"] + expected_new_mode_message = "The onboarding process is not applied." + result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name2, session_id="session1") + + # the new mode's prompt must be included in the activation message + self._assert_activation_message(result2, project_name2, present=True) + assert expected_new_mode_message in result2, ( + f"Expected new mode message '{expected_new_mode_message}' not found in result:\n{result2}" + ) + + # the mode prompt must not be included in subsequent calls to the initial instructions tool within the same session + result3 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session1") + assert expected_new_mode_message not in result3, ( + f"Expected new mode message '{expected_new_mode_message}' to not be included in subsequent calls, but it was found in result:\n{result3}" + ) + + # the mode prompt must be included in the initial instructions of a new session + result4 = self._call_tool(serena_agent, InitialInstructionsTool, session_id="session2") + assert expected_new_mode_message in result4, ( + f"Expected new mode message '{expected_new_mode_message}' to be included in new session, but it was not found in result:\n{result4}" + ) + + # the initial instructions for the new session must also include the activation message for the project + self._assert_activation_message(result4, project_name2, present=True)