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
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-04-20 12:18:07 +02:00
1 parent b3c92d32d8
commit 3d31512107
5 files changed
+72 -37

No files matched your search

+7 -4
View File
@@ -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)
+53 -23
View File
@@ -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
+1 -3
View File
@@ -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:
+7 -5
View File
@@ -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
+4 -2
View File
@@ -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)