refactor: split system prompt into per-concern extensions with extensibility hooks

- split monolithic _10_system_prompt.py into focused extensions: main (10), tools (11), mcp (12), skills (13), secrets (13), project (14) - each extension exposes a build_prompt() function with call_extensions_async hook for plugin extensibility - move tool prompt collection from VariablesPlugin to _11_tools_prompt using subagents.get_paths for proper directory coverage - add {{include original}} directive to process_includes allowing prompt inheritance without copy-paste - add agent.system.main.specifics.md for subagent-specific additions without overriding entire role - remove redundant plugin prompt extensions (_15_text_editor, _20_code_execution) that duplicated tool collection - add _09_text_editor_config to register per-file kwargs via agent.data instead of VariablesPlugin in prompts dir

linuztx committed Mar 20, 2026 at 12:12 UTC 2566ee134d5a464777f956f4290d4a9d0d802b69
16 files changed +302 -184
extensions/python/system_prompt/_10_main_prompt.py new
+24
@@ -0,0 +1,24 @@
1 +from typing import Any
2 +
3 +from helpers.extension import Extension, call_extensions_async
4 +from agent import Agent, LoopData
5 +
6 +
7 +class MainPrompt(Extension):
8 +
9 + async def execute(
10 + self,
11 + system_prompt: list[str] = [],
12 + loop_data: LoopData = LoopData(),
13 + **kwargs: Any,
14 + ):
15 + if not self.agent:
16 + return
17 + prompt = await build_prompt(self.agent)
18 + system_prompt.append(prompt)
19 +
20 +
21 +async def build_prompt(agent: Agent) -> str:
22 + data: dict[str, Any] = {"prompt": agent.read_prompt("agent.system.main.md")}
23 + await call_extensions_async("system_prompt_main", agent=agent, data=data)
24 + return data["prompt"]
extensions/python/system_prompt/_10_system_prompt.py deleted
-101
@@ -1,101 +0,0 @@
1 -from typing import Any
2 -from helpers.extension import Extension
3 -from helpers.mcp_handler import MCPConfig
4 -from agent import Agent, LoopData
5 -from helpers.settings import get_settings
6 -from helpers import projects, skills
7 -
8 -
9 -class SystemPrompt(Extension):
10 -
11 - async def execute(
12 - self,
13 - system_prompt: list[str] = [],
14 - loop_data: LoopData = LoopData(),
15 - **kwargs: Any
16 - ):
17 - if not self.agent:
18 - return
19 -
20 - # append main system prompt and tools
21 - main = get_main_prompt(self.agent)
22 - tools = get_tools_prompt(self.agent)
23 - mcp_tools = get_mcp_tools_prompt(self.agent)
24 - skills = get_skills_prompt(self.agent)
25 - secrets_prompt = get_secrets_prompt(self.agent)
26 - project_prompt = get_project_prompt(self.agent)
27 -
28 - system_prompt.append(main)
29 - system_prompt.append(tools)
30 - if mcp_tools:
31 - system_prompt.append(mcp_tools)
32 - if skills:
33 - system_prompt.append(skills)
34 - if secrets_prompt:
35 - system_prompt.append(secrets_prompt)
36 - if project_prompt:
37 - system_prompt.append(project_prompt)
38 -
39 -
40 -def get_main_prompt(agent: Agent):
41 - return agent.read_prompt("agent.system.main.md")
42 -
43 -
44 -def get_tools_prompt(agent: Agent):
45 - from plugins._model_config.helpers.model_config import get_chat_model_config
46 - prompt = agent.read_prompt("agent.system.tools.md")
47 - chat_cfg = get_chat_model_config(agent)
48 - if chat_cfg.get("vision", False):
49 - prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
50 - return prompt
51 -
52 -
53 -def get_mcp_tools_prompt(agent: Agent):
54 - mcp_config = MCPConfig.get_instance()
55 - if mcp_config.servers:
56 - pre_progress = agent.context.log.progress
57 - agent.context.log.set_progress(
58 - "Collecting MCP tools"
59 - ) # MCP might be initializing, better inform via progress bar
60 - tools = MCPConfig.get_instance().get_tools_prompt()
61 - agent.context.log.set_progress(pre_progress) # return original progress
62 - return tools
63 - return ""
64 -
65 -
66 -def get_secrets_prompt(agent: Agent):
67 - try:
68 - # Use lazy import to avoid circular dependencies
69 - from helpers.secrets import get_secrets_manager
70 -
71 - secrets_manager = get_secrets_manager(agent.context)
72 - secrets = secrets_manager.get_secrets_for_prompt()
73 - vars = get_settings()["variables"]
74 - return agent.read_prompt("agent.system.secrets.md", secrets=secrets, vars=vars)
75 - except Exception as e:
76 - # If secrets module is not available or has issues, return empty string
77 - return ""
78 -
79 -
80 -def get_project_prompt(agent: Agent):
81 - result = agent.read_prompt("agent.system.projects.main.md")
82 - project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
83 - if project_name:
84 - project_vars = projects.build_system_prompt_vars(project_name)
85 - result += "\n\n" + agent.read_prompt(
86 - "agent.system.projects.active.md", **project_vars
87 - )
88 - else:
89 - result += "\n\n" + agent.read_prompt("agent.system.projects.inactive.md")
90 - return result
91 -
92 -def get_skills_prompt(agent: Agent):
93 - available = skills.list_skills(agent=agent)
94 - result = []
95 - for skill in available:
96 - name = skill.name.strip().replace("\n", " ")[:100]
97 - descr = skill.description.replace("\n", " ")[:500]
98 - result.append(f"**{name}** {descr}")
99 -
100 - if result:
101 - return agent.read_prompt("agent.system.skills.md", skills="\n".join(result))
extensions/python/system_prompt/_11_tools_prompt.py new
+59
@@ -0,0 +1,59 @@
1 +import os
2 +from typing import Any
3 +
4 +from helpers.extension import Extension, call_extensions_async
5 +from helpers import files, subagents
6 +from helpers.print_style import PrintStyle
7 +from agent import Agent, LoopData
8 +
9 +
10 +TOOL_KWARGS_KEY = "_tool_prompt_kwargs"
11 +
12 +
13 +class ToolsPrompt(Extension):
14 +
15 + async def execute(
16 + self,
17 + system_prompt: list[str] = [],
18 + loop_data: LoopData = LoopData(),
19 + **kwargs: Any,
20 + ):
21 + if not self.agent:
22 + return
23 + prompt = await build_prompt(self.agent)
24 + system_prompt.append(prompt)
25 +
26 +
27 +async def build_prompt(agent: Agent) -> str:
28 + # collect tool files from all prompt directories
29 + prompt_dirs = subagents.get_paths(agent, "prompts")
30 + tool_files = files.get_unique_filenames_in_dirs(
31 + prompt_dirs, "agent.system.tool.*.md"
32 + )
33 +
34 + # per-file kwargs registered by plugin config extensions (e.g. _09_text_editor_config)
35 + all_tool_kwargs: dict[str, dict[str, Any]] = agent.get_data(TOOL_KWARGS_KEY) or {}
36 +
37 + tools: list[str] = []
38 + for tool_file in tool_files:
39 + try:
40 + basename = os.path.basename(tool_file)
41 + extra = all_tool_kwargs.get(basename, {})
42 + tool = agent.read_prompt(basename, **extra)
43 + tools.append(tool)
44 + except Exception as e:
45 + PrintStyle().error(f"Error loading tool '{tool_file}': {e}")
46 +
47 + tools_str = "\n\n".join(tools)
48 + prompt = agent.read_prompt("agent.system.tools.md", tools=tools_str)
49 +
50 + # vision support
51 + from plugins._model_config.helpers.model_config import get_chat_model_config
52 +
53 + chat_cfg = get_chat_model_config(agent)
54 + if chat_cfg.get("vision", False):
55 + prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
56 +
57 + data: dict[str, Any] = {"prompt": prompt}
58 + await call_extensions_async("system_prompt_tools", agent=agent, data=data)
59 + return data["prompt"]
extensions/python/system_prompt/_12_mcp_prompt.py new
+35
@@ -0,0 +1,35 @@
1 +from typing import Any
2 +
3 +from helpers.extension import Extension, call_extensions_async
4 +from helpers.mcp_handler import MCPConfig
5 +from agent import Agent, LoopData
6 +
7 +
8 +class MCPToolsPrompt(Extension):
9 +
10 + async def execute(
11 + self,
12 + system_prompt: list[str] = [],
13 + loop_data: LoopData = LoopData(),
14 + **kwargs: Any,
15 + ):
16 + if not self.agent:
17 + return
18 + prompt = await build_prompt(self.agent)
19 + if prompt:
20 + system_prompt.append(prompt)
21 +
22 +
23 +async def build_prompt(agent: Agent) -> str:
24 + mcp_config = MCPConfig.get_instance()
25 + if not mcp_config.servers:
26 + return ""
27 +
28 + pre_progress = agent.context.log.progress
29 + agent.context.log.set_progress("Collecting MCP tools")
30 + tools = mcp_config.get_tools_prompt()
31 + agent.context.log.set_progress(pre_progress)
32 +
33 + data: dict[str, Any] = {"prompt": tools}
34 + await call_extensions_async("system_prompt_mcp", agent=agent, data=data)
35 + return data["prompt"]
extensions/python/system_prompt/_13_secrets_prompt.py new
+38
@@ -0,0 +1,38 @@
1 +from typing import Any
2 +
3 +from helpers.extension import Extension, call_extensions_async
4 +from agent import Agent, LoopData
5 +
6 +
7 +class SecretsPrompt(Extension):
8 +
9 + async def execute(
10 + self,
11 + system_prompt: list[str] = [],
12 + loop_data: LoopData = LoopData(),
13 + **kwargs: Any,
14 + ):
15 + if not self.agent:
16 + return
17 + prompt = await build_prompt(self.agent)
18 + if prompt:
19 + system_prompt.append(prompt)
20 +
21 +
22 +async def build_prompt(agent: Agent) -> str:
23 + try:
24 + from helpers.secrets import get_secrets_manager
25 + from helpers.settings import get_settings
26 +
27 + secrets_manager = get_secrets_manager(agent.context)
28 + secrets = secrets_manager.get_secrets_for_prompt()
29 + variables = get_settings()["variables"]
30 + prompt = agent.read_prompt(
31 + "agent.system.secrets.md", secrets=secrets, vars=variables
32 + )
33 +
34 + data: dict[str, Any] = {"prompt": prompt}
35 + await call_extensions_async("system_prompt_secrets", agent=agent, data=data)
36 + return data["prompt"]
37 + except Exception:
38 + return ""
extensions/python/system_prompt/_13_skills_prompt.py new
+38
@@ -0,0 +1,38 @@
1 +from typing import Any
2 +
3 +from helpers.extension import Extension, call_extensions_async
4 +from helpers import skills as skills_helper
5 +from agent import Agent, LoopData
6 +
7 +
8 +class SkillsPrompt(Extension):
9 +
10 + async def execute(
11 + self,
12 + system_prompt: list[str] = [],
13 + loop_data: LoopData = LoopData(),
14 + **kwargs: Any,
15 + ):
16 + if not self.agent:
17 + return
18 + prompt = await build_prompt(self.agent)
19 + if prompt:
20 + system_prompt.append(prompt)
21 +
22 +
23 +async def build_prompt(agent: Agent) -> str:
24 + available = skills_helper.list_skills(agent=agent)
25 + result: list[str] = []
26 + for skill in available:
27 + name = skill.name.strip().replace("\n", " ")[:100]
28 + descr = skill.description.replace("\n", " ")[:500]
29 + result.append(f"**{name}** {descr}")
30 +
31 + if not result:
32 + return ""
33 +
34 + prompt = agent.read_prompt("agent.system.skills.md", skills="\n".join(result))
35 +
36 + data: dict[str, Any] = {"prompt": prompt}
37 + await call_extensions_async("system_prompt_skills", agent=agent, data=data)
38 + return data["prompt"]
extensions/python/system_prompt/_14_project_prompt.py new
+36
@@ -0,0 +1,36 @@
1 +from typing import Any
2 +
3 +from helpers.extension import Extension, call_extensions_async
4 +from helpers import projects
5 +from agent import Agent, LoopData
6 +
7 +
8 +class ProjectPrompt(Extension):
9 +
10 + async def execute(
11 + self,
12 + system_prompt: list[str] = [],
13 + loop_data: LoopData = LoopData(),
14 + **kwargs: Any,
15 + ):
16 + if not self.agent:
17 + return
18 + prompt = await build_prompt(self.agent)
19 + if prompt:
20 + system_prompt.append(prompt)
21 +
22 +
23 +async def build_prompt(agent: Agent) -> str:
24 + result = agent.read_prompt("agent.system.projects.main.md")
25 + project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
26 + if project_name:
27 + project_vars = projects.build_system_prompt_vars(project_name)
28 + result += "\n\n" + agent.read_prompt(
29 + "agent.system.projects.active.md", **project_vars
30 + )
31 + else:
32 + result += "\n\n" + agent.read_prompt("agent.system.projects.inactive.md")
33 +
34 + data: dict[str, Any] = {"prompt": result}
35 + await call_extensions_async("system_prompt_project", agent=agent, data=data)
36 + return data["prompt"]
helpers/files.py
+44 -10
@@ -133,10 +133,10 @@ def read_prompt_file(
133
134 # Find the file in the directories
135 absolute_path = find_file_in_dirs(_file, _directories)
136 + source_dir = os.path.dirname(absolute_path)
137
138 # Read the file content
139 with open(absolute_path, "r", encoding=_encoding) as f:
139 - # content = remove_code_fences(f.read())
140 content = f.read()
141
142 variables = load_plugin_variables(_file, _directories, **kwargs) or {} # type: ignore
@@ -148,11 +148,13 @@ def read_prompt_file(
148 # Replace placeholders with values from kwargs
149 content = replace_placeholders_text(content, **variables)
150
151 - # Process include statements
151 + # Process include statements (with source tracking for {{include original}})
152 content = process_includes(
153 # here we use kwargs, the plugin variables are not inherited
154 content,
155 _directories,
156 + _source_file=_file,
157 + _source_dir=source_dir,
158 **kwargs,
159 )
160
@@ -326,26 +328,58 @@ def replace_placeholders_dict(_content: dict, **kwargs):
328 return replace_value(_content)
329
330
329 -def process_includes(_content: str, _directories: list[str], **kwargs):
330 - # Regex to find {{ include 'path' }} or {{include'path'}}
331 +def process_includes(
332 + _content: str,
333 + _directories: list[str],
334 + _source_file: str = "",
335 + _source_dir: str = "",
336 + **kwargs,
337 +):
338 + # {{include original}} — include same file from lower-priority directory
339 + original_pattern = re.compile(r"{{\s*include\s+original\s*}}")
340 +
341 + def replace_original(match):
342 + if not _source_file or not _source_dir:
343 + return match.group(0)
344 + remaining_dirs = _get_dirs_after(_directories, _source_dir)
345 + if not remaining_dirs:
346 + return ""
347 + try:
348 + return read_prompt_file(_source_file, remaining_dirs, **kwargs)
349 + except FileNotFoundError:
350 + return ""
351 +
352 + _content = re.sub(original_pattern, replace_original, _content)
353 +
354 + # {{ include 'path' }} — include a named file
355 include_pattern = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")
356
357 def replace_include(match):
358 include_path = match.group(1)
335 - # if the path is absolute, do not process it
359 if os.path.isabs(include_path):
360 return match.group(0)
338 - # Search for the include file in the directories
361 try:
340 - included_content = read_prompt_file(include_path, _directories, **kwargs)
341 - return included_content
362 + return read_prompt_file(include_path, _directories, **kwargs)
363 except FileNotFoundError:
343 - return match.group(0) # Return original if file not found
364 + return match.group(0)
365
345 - # Replace all includes with the file content
366 return re.sub(include_pattern, replace_include, _content)
367
368
369 +def _get_dirs_after(_directories: list[str], _source_dir: str) -> list[str]:
370 + """Return directories after _source_dir in the priority list."""
371 + source_abs = os.path.normpath(os.path.abspath(_source_dir))
372 + found = False
373 + result: list[str] = []
374 + for d in _directories:
375 + d_abs = os.path.normpath(os.path.abspath(get_abs_path(d)))
376 + if found:
377 + result.append(d)
378 + elif d_abs == source_abs:
379 + found = True
380 + return result
381 +
382 +
383 def find_file_in_dirs(_filename: str, _directories: list[str]):
384 """
385 This function searches for a filename in a list of directories in order.
plugins/_code_execution/extensions/.gitkeep
plugins/_code_execution/extensions/python/system_prompt/_20_code_execution_prompt.py deleted
-17
@@ -1,17 +0,0 @@
1 -from helpers.extension import Extension
2 -from agent import LoopData
3 -
4 -
5 -class CodeExecutionPrompt(Extension):
6 -
7 - async def execute(
8 - self,
9 - system_prompt: list[str] = [],
10 - loop_data: LoopData = LoopData(),
11 - **kwargs,
12 - ):
13 - if not self.agent:
14 - return
15 -
16 - system_prompt.append(self.agent.read_prompt("agent.system.tool.code_exe.md"))
17 - system_prompt.append(self.agent.read_prompt("agent.system.tool.input.md"))
plugins/_text_editor/extensions/python/system_prompt/_09_text_editor_config.py new
+23
@@ -0,0 +1,23 @@
1 +from typing import Any
2 +from helpers.extension import Extension
3 +from helpers import plugins
4 +from agent import LoopData
5 +
6 +TOOL_KWARGS_KEY = "_tool_prompt_kwargs"
7 +
8 +
9 +class TextEditorConfig(Extension):
10 +
11 + async def execute(
12 + self,
13 + system_prompt: list[str] = [],
14 + loop_data: LoopData = LoopData(),
15 + **kwargs: Any,
16 + ):
17 + if not self.agent:
18 + return
19 + config = plugins.get_plugin_config("_text_editor", agent=self.agent) or {}
20 + tool_kwargs = self.agent.data.setdefault(TOOL_KWARGS_KEY, {})
21 + tool_kwargs["agent.system.tool.text_editor.md"] = {
22 + "default_line_count": config.get("default_line_count", 100),
23 + }
plugins/_text_editor/extensions/python/system_prompt/_15_text_editor_prompt.py deleted
-23
@@ -1,23 +0,0 @@
1 -from helpers.extension import Extension
2 -from helpers import plugins
3 -from agent import Agent, LoopData
4 -
5 -
6 -class TextEditorPrompt(Extension):
7 -
8 - async def execute(
9 - self,
10 - system_prompt: list[str] = [],
11 - loop_data: LoopData = LoopData(),
12 - **kwargs,
13 - ):
14 - if not self.agent:
15 - return
16 -
17 - config = plugins.get_plugin_config("_text_editor", agent=self.agent) or {}
18 - default_line_count = config.get("default_line_count", 100)
19 - prompt = self.agent.read_prompt(
20 - "agent.system.tool.text_editor.md",
21 - default_line_count=default_line_count,
22 - )
23 - system_prompt.append(prompt)
prompts/agent.system.main.md
+2
@@ -9,3 +9,5 @@
9 {{ include "agent.system.main.solving.md" }}
10
11 {{ include "agent.system.main.tips.md" }}
12 +
13 +{{ include "agent.system.main.specifics.md" }}
prompts/agent.system.main.specifics.md
prompts/agent.system.tools.py deleted
-30
@@ -1,30 +0,0 @@
1 -import os
2 -from typing import Any
3 -from helpers.files import VariablesPlugin
4 -from helpers import files
5 -from helpers.print_style import PrintStyle
6 -
7 -
8 -class BuidToolsPrompt(VariablesPlugin):
9 - def get_variables(self, file: str, backup_dirs: list[str] | None = None, **kwargs) -> dict[str, Any]:
10 -
11 - # collect all prompt folders in order of their priority
12 - folder = files.get_abs_path(os.path.dirname(file))
13 - folders = [folder]
14 - if backup_dirs:
15 - for backup_dir in backup_dirs:
16 - folders.append(files.get_abs_path(backup_dir))
17 -
18 - # collect all tool instruction files
19 - prompt_files = files.get_unique_filenames_in_dirs(folders, "agent.system.tool.*.md")
20 -
21 - # load tool instructions
22 - tools = []
23 - for prompt_file in prompt_files:
24 - try:
25 - tool = files.read_prompt_file(prompt_file, **kwargs)
26 - tools.append(tool)
27 - except Exception as e:
28 - PrintStyle().error(f"Error loading tool '{prompt_file}': {e}")
29 -
30 - return {"tools": "\n\n".join(tools)}
tools/unknown.py
+3 -3
@@ -1,12 +1,12 @@
1 from helpers.tool import Tool, Response
2 -from extensions.python.system_prompt._10_system_prompt import (
3 - get_tools_prompt,
2 +from extensions.python.system_prompt._11_tools_prompt import (
3 + build_prompt as build_tools_prompt
4 )
5
6
7 class Unknown(Tool):
8 async def execute(self, **kwargs):
9 - tools = get_tools_prompt(self.agent)
9 + tools = await build_tools_prompt(self.agent)
10 return Response(
11 message=self.agent.read_prompt(
12 "fw.tool_not_found.md", tool_name=self.name, tools_prompt=tools