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)