subagents preparation

frdel committed Nov 26, 2025 at 09:07 UTC 11d1cb0e645f942c83cc5b9a597319d837f0e391
16 files changed +703 -150
agent.py
+84 -55
@@ -11,8 +11,16 @@ from enum import Enum
11 import uuid
12 import models
13
14 -from python.helpers import extract_tools, files, errors, history, tokens, context as context_helper
15 -from python.helpers import dirty_json
14 +from python.helpers import (
15 + extract_tools,
16 + files,
17 + errors,
18 + history,
19 + tokens,
20 + context as context_helper,
21 + dirty_json,
22 + subagents
23 +)
24 from python.helpers.print_style import PrintStyle
25
26 from langchain_core.prompts import (
@@ -69,9 +77,10 @@ class AgentContext:
77 # initialize state
78 self.name = name
79 self.config = config
80 + self.data = data or {}
81 + self.output_data = output_data or {}
82 self.log = log or Log.Log()
83 self.log.context = self
74 - self.agent0 = agent0 or Agent(0, self.config, self)
84 self.paused = paused
85 self.streaming_agent = streaming_agent
86 self.task: DeferredTask | None = None
@@ -80,10 +89,9 @@ class AgentContext:
89 AgentContext._counter += 1
90 self.no = AgentContext._counter
91 self.last_message = last_message or datetime.now(timezone.utc)
83 - self.data = data or {}
84 - self.output_data = output_data or {}
85 -
92
93 + # initialize agent at last (context is complete now)
94 + self.agent0 = agent0 or Agent(0, self.config, self)
95
96 @staticmethod
97 def get(id: str):
@@ -100,7 +108,7 @@ class AgentContext:
108
109 @staticmethod
110 def current():
103 - ctxid = context_helper.get_context_data("agent_context_id","")
111 + ctxid = context_helper.get_context_data("agent_context_id", "")
112 if not ctxid:
113 return None
114 return AgentContext.get(ctxid)
@@ -122,7 +130,8 @@ class AgentContext:
130 @staticmethod
131 def generate_id():
132 def generate_short_id():
125 - return ''.join(random.choices(string.ascii_letters + string.digits, k=8))
133 + return "".join(random.choices(string.ascii_letters + string.digits, k=8))
134 +
135 while True:
136 short_id = generate_short_id()
137 if short_id not in AgentContext._contexts:
@@ -132,6 +141,7 @@ class AgentContext:
141 def get_notification_manager(cls):
142 if cls._notification_manager is None:
143 from python.helpers.notification import NotificationManager # type: ignore
144 +
145 cls._notification_manager = NotificationManager()
146 return cls._notification_manager
147
@@ -269,7 +279,6 @@ class AgentContext:
279 agent.handle_critical_exception(e)
280
281
272 -
282 @dataclass
283 class AgentConfig:
284 chat_model: models.ModelConfig
@@ -280,7 +289,9 @@ class AgentConfig:
289 profile: str = ""
290 memory_subdir: str = ""
291 knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"])
283 - browser_http_headers: dict[str, str] = field(default_factory=dict) # Custom HTTP headers for browser requests
292 + browser_http_headers: dict[str, str] = field(
293 + default_factory=dict
294 + ) # Custom HTTP headers for browser requests
295 code_exec_ssh_enabled: bool = True
296 code_exec_ssh_addr: str = "localhost"
297 code_exec_ssh_port: int = 55022
@@ -380,7 +391,9 @@ class Agent:
391 prompt = await self.prepare_prompt(loop_data=self.loop_data)
392
393 # call before_main_llm_call extensions
383 - await self.call_extensions("before_main_llm_call", loop_data=self.loop_data)
394 + await self.call_extensions(
395 + "before_main_llm_call", loop_data=self.loop_data
396 + )
397
398 async def reasoning_callback(chunk: str, full: str):
399 await self.handle_intervention()
@@ -389,7 +402,9 @@ class Agent:
402 # Pass chunk and full data to extensions for processing
403 stream_data = {"chunk": chunk, "full": full}
404 await self.call_extensions(
392 - "reasoning_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
405 + "reasoning_stream_chunk",
406 + loop_data=self.loop_data,
407 + stream_data=stream_data,
408 )
409 # Stream masked chunk after extensions processed it
410 if stream_data.get("chunk"):
@@ -405,7 +420,9 @@ class Agent:
420 # Pass chunk and full data to extensions for processing
421 stream_data = {"chunk": chunk, "full": full}
422 await self.call_extensions(
408 - "response_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
423 + "response_stream_chunk",
424 + loop_data=self.loop_data,
425 + stream_data=stream_data,
426 )
427 # Stream masked chunk after extensions processed it
428 if stream_data.get("chunk"):
@@ -570,27 +587,15 @@ class Agent:
587 return system_prompt
588
589 def parse_prompt(self, _prompt_file: str, **kwargs):
573 - dirs = [files.get_abs_path("prompts")]
574 - if (
575 - self.config.profile
576 - ): # if agent has custom folder, use it and use default as backup
577 - prompt_dir = files.get_abs_path("agents", self.config.profile, "prompts")
578 - dirs.insert(0, prompt_dir)
590 + dirs = subagents.get_agent_paths_chain(self, "prompts")
591 prompt = files.parse_file(
580 - _prompt_file, _directories=dirs, **kwargs
592 + _prompt_file, _directories=dirs, _agent=self, **kwargs
593 )
594 return prompt
595
596 def read_prompt(self, file: str, **kwargs) -> str:
585 - dirs = [files.get_abs_path("prompts")]
586 - if (
587 - self.config.profile
588 - ): # if agent has custom folder, use it and use default as backup
589 - prompt_dir = files.get_abs_path("agents", self.config.profile, "prompts")
590 - dirs.insert(0, prompt_dir)
591 - prompt = files.read_prompt_file(
592 - file, _directories=dirs, **kwargs
593 - )
597 + dirs = subagents.get_agent_paths_chain(self, "prompts")
598 + prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
599 prompt = files.remove_code_fences(prompt)
600 return prompt
601
@@ -606,8 +611,12 @@ class Agent:
611 self.last_message = datetime.now(timezone.utc)
612 # Allow extensions to process content before adding to history
613 content_data = {"content": content}
609 - asyncio.run(self.call_extensions("hist_add_before", content_data=content_data, ai=ai))
610 - return self.history.add_message(ai=ai, content=content_data["content"], tokens=tokens)
614 + asyncio.run(
615 + self.call_extensions("hist_add_before", content_data=content_data, ai=ai)
616 + )
617 + return self.history.add_message(
618 + ai=ai, content=content_data["content"], tokens=tokens
619 + )
620
621 def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
622 self.history.new_topic() # user message starts a new topic in history
@@ -720,7 +729,9 @@ class Agent:
729 system_message=call_data["system"],
730 user_message=call_data["message"],
731 response_callback=stream_callback if call_data["callback"] else None,
723 - rate_limiter_callback=self.rate_limiter_callback if not call_data["background"] else None,
732 + rate_limiter_callback=(
733 + self.rate_limiter_callback if not call_data["background"] else None
734 + ),
735 )
736
737 return response
@@ -742,7 +753,9 @@ class Agent:
753 messages=messages,
754 reasoning_callback=reasoning_callback,
755 response_callback=response_callback,
745 - rate_limiter_callback=self.rate_limiter_callback if not background else None,
756 + rate_limiter_callback=(
757 + self.rate_limiter_callback if not background else None
758 + ),
759 )
760
761 return response, reasoning
@@ -817,11 +830,15 @@ class Agent:
830 # Fallback to local get_tool if MCP tool was not found or MCP lookup failed
831 if not tool:
832 tool = self.get_tool(
820 - name=tool_name, method=tool_method, args=tool_args, message=msg, loop_data=self.loop_data
833 + name=tool_name,
834 + method=tool_method,
835 + args=tool_args,
836 + message=msg,
837 + loop_data=self.loop_data,
838 )
839
840 if tool:
824 - self.loop_data.current_tool = tool # type: ignore
841 + self.loop_data.current_tool = tool # type: ignore
842 try:
843 await self.handle_intervention()
844
@@ -830,14 +847,20 @@ class Agent:
847 await self.handle_intervention()
848
849 # Allow extensions to preprocess tool arguments
833 - await self.call_extensions("tool_execute_before", tool_args=tool_args or {}, tool_name=tool_name)
850 + await self.call_extensions(
851 + "tool_execute_before",
852 + tool_args=tool_args or {},
853 + tool_name=tool_name,
854 + )
855
856 response = await tool.execute(**tool_args)
857 await self.handle_intervention()
858
859 # Allow extensions to postprocess tool response
839 - await self.call_extensions("tool_execute_after", response=response, tool_name=tool_name)
840 -
860 + await self.call_extensions(
861 + "tool_execute_after", response=response, tool_name=tool_name
862 + )
863 +
864 await tool.after_execution(response)
865 await self.handle_intervention()
866
@@ -889,34 +912,40 @@ class Agent:
912 pass
913
914 def get_tool(
892 - self, name: str, method: str | None, args: dict, message: str, loop_data: LoopData | None, **kwargs
915 + self,
916 + name: str,
917 + method: str | None,
918 + args: dict,
919 + message: str,
920 + loop_data: LoopData | None,
921 + **kwargs,
922 ):
923 from python.tools.unknown import Unknown
924 from python.helpers.tool import Tool
925
926 classes = []
927
899 - # try agent tools first
900 - if self.config.profile:
928 + # search for tools in agent's folder hierarchy
929 + paths = subagents.get_agent_paths_chain(self, "tools", name + ".py", default_root="python")
930 + for path in paths:
931 try:
902 - classes = extract_tools.load_classes_from_file(
903 - "agents/" + self.config.profile + "/tools/" + name + ".py", Tool # type: ignore[arg-type]
904 - )
932 + classes = extract_tools.load_classes_from_file(path, Tool) # type: ignore[arg-type]
933 + break
934 except Exception:
906 - pass
935 + continue
936
908 - # try default tools
909 - if not classes:
910 - try:
911 - classes = extract_tools.load_classes_from_file(
912 - "python/tools/" + name + ".py", Tool # type: ignore[arg-type]
913 - )
914 - except Exception as e:
915 - pass
937 tool_class = classes[0] if classes else Unknown
938 return tool_class(
918 - agent=self, name=name, method=method, args=args, message=message, loop_data=loop_data, **kwargs
939 + agent=self,
940 + name=name,
941 + method=method,
942 + args=args,
943 + message=message,
944 + loop_data=loop_data,
945 + **kwargs,
946 )
947
948 async def call_extensions(self, extension_point: str, **kwargs) -> Any:
922 - return await call_extensions(extension_point=extension_point, agent=self, **kwargs)
949 + return await call_extensions(
950 + extension_point=extension_point, agent=self, **kwargs
951 + )
agents/agent0/agent.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "title": "Agent 0",
3 + "description": "Main agent of the system communicating directly with the user.",
4 + "context": ""
5 +}
agents/default/agent.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "title": "Default prompts",
3 + "description": "Default prompt file templates. Should be inherited and overriden by specialized prompt profiles.",
4 + "context": ""
5 +}
agents/developer/agent.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "title": "Developer",
3 + "description": "Agent specialized in complex software development.",
4 + "context": "Use this agent for software development tasks, including writing code, debugging, refactoring, and architectural design."
5 +}
agents/hacker/agent.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "title": "Hacker",
3 + "description": "Agent specialized in cyber security and penetration testing.",
4 + "context": "Use this agent for cybersecurity tasks such as penetration testing, vulnerability analysis, and security auditing."
5 +}
agents/researcher/agent.json new
+5
@@ -0,0 +1,5 @@
1 +{
2 + "title": "Researcher",
3 + "description": "Agent specialized in research, data analysis and reporting.",
4 + "context": "Use this agent for information gathering, data analysis, topic research, and generating comprehensive reports."
5 +}
prompts/agent.system.tool.call_sub.md
+3 -1
@@ -1,3 +1,4 @@
1 +{{if agent_profiles}}
2 ### call_subordinate
3
4 you can use subordinates for subtasks
@@ -31,4 +32,5 @@ example usage
32 - you might be part of long chain of subordinates, avoid slow and expensive rewriting subordinate responses, instead use `§§include(<path>)` alias to include the response as is
33
34 **available profiles:**
34 -{{agent_profiles}}
\ No newline at end of file
35 +{{agent_profiles}}
36 +{{endif}}
\ No newline at end of file
prompts/agent.system.tool.call_sub.py
+26 -23
@@ -1,31 +1,34 @@
1 import json
2 -from typing import Any
2 +from typing import Any, TYPE_CHECKING
3 from python.helpers.files import VariablesPlugin
4 -from python.helpers import files
4 +from python.helpers import files, projects, subagents
5 from python.helpers.print_style import PrintStyle
6
7 +if TYPE_CHECKING:
8 + from agent import Agent
9
8 -class CallSubordinate(VariablesPlugin):
9 - def get_variables(self, file: str, backup_dirs: list[str] | None = None) -> dict[str, Any]:
10
11 - # collect all prompt profiles from subdirectories (_context.md file)
12 - profiles = []
13 - agent_subdirs = files.get_subdirectories("agents", exclude=["_example"])
14 - for agent_subdir in agent_subdirs:
15 - try:
16 - context = files.read_prompt_file(
17 - "_context.md",
18 - [files.get_abs_path("agents", agent_subdir)]
19 - )
20 - profiles.append({"name": agent_subdir, "context": context})
21 - except Exception as e:
22 - PrintStyle().error(f"Error loading agent profile '{agent_subdir}': {e}")
11 +class CallSubordinate(VariablesPlugin):
12 + def get_variables(
13 + self, file: str, backup_dirs: list[str] | None = None, **kwargs
14 + ) -> dict[str, Any]:
15
24 - # in case of no profiles
25 - if not profiles:
26 - # PrintStyle().error("No agent profiles found")
27 - profiles = [
28 - {"name": "default", "context": "Default Agent-Zero AI Assistant"}
29 - ]
16 + # current agent instance
17 + agent: Agent | None = kwargs.get("_agent", None)
18 + # current project
19 + project = projects.get_context_project_name(agent.context) if agent else None
20 + # available agents in project (or global)
21 + agents = subagents.get_available_agents_dict(project)
22
31 - return {"agent_profiles": profiles}
23 + if agents:
24 + profiles = {}
25 + for name, subagent in agents.items():
26 + profiles[name] = {
27 + "title": subagent.title,
28 + "description": subagent.description,
29 + "context": subagent.context,
30 + }
31 + return {"agent_profiles": profiles}
32 + else:
33 + return {"agent_profiles": None}
34 +
prompts/agent.system.tools.py
+3 -3
@@ -5,8 +5,8 @@ from python.helpers import files
5 from python.helpers.print_style import PrintStyle
6
7
8 -class CallSubordinate(VariablesPlugin):
9 - def get_variables(self, file: str, backup_dirs: list[str] | None = None) -> dict[str, Any]:
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))
@@ -22,7 +22,7 @@ class CallSubordinate(VariablesPlugin):
22 tools = []
23 for prompt_file in prompt_files:
24 try:
25 - tool = files.read_prompt_file(prompt_file)
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}")
python/api/subagents.py new
+58
@@ -0,0 +1,58 @@
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +from python.helpers import subagents
3 +from typing import TYPE_CHECKING
4 +
5 +if TYPE_CHECKING:
6 + from python.helpers import projects
7 +
8 +class Subagents(ApiHandler):
9 + async def process(self, input: Input, request: Request) -> Output:
10 + action = input.get("action", "")
11 + ctxid = input.get("context_id", None)
12 +
13 + if ctxid:
14 + _context = self.use_context(ctxid)
15 +
16 + try:
17 + if action == "list":
18 + data = self.get_subagents_list()
19 + elif action == "load":
20 + data = self.load_agent(input.get("name", None))
21 + elif action == "save":
22 + data = self.save_agent(input.get("name", None), input.get("data", None))
23 + elif action == "delete":
24 + data = self.delete_agent(input.get("name", None))
25 + else:
26 + raise Exception("Invalid action")
27 +
28 + return {
29 + "ok": True,
30 + "data": data,
31 + }
32 + except Exception as e:
33 + return {
34 + "ok": False,
35 + "error": str(e),
36 + }
37 +
38 + def get_subagents_list(self):
39 + return subagents.get_agents_list()
40 +
41 + def load_agent(self, name: str|None):
42 + if name is None:
43 + raise Exception("Subagent name is required")
44 + return subagents.load_agent_data(name)
45 +
46 + def save_agent(self, name:str|None, data: dict|None):
47 + if name is None:
48 + raise Exception("Subagent name is required")
49 + if data is None:
50 + raise Exception("Subagent data is required")
51 + subagent = subagents.SubAgent(**data)
52 + subagents.save_agent_data(name, subagent)
53 + return subagents.load_agent_data(name)
54 +
55 + def delete_agent(self, name: str|None):
56 + if name is None:
57 + raise Exception("Subagent name is required")
58 + subagents.delete_agent_data(name)
\ No newline at end of file
python/extensions/agent_init/_15_load_profile_settings.py
+37 -37
@@ -1,5 +1,5 @@
1 from initialize import initialize_agent
2 -from python.helpers import dirty_json, files
2 +from python.helpers import dirty_json, files, subagents, projects
3 from python.helpers.extension import Extension
4
5
@@ -10,44 +10,44 @@ class LoadProfileSettings(Extension):
10 if not self.agent or not self.agent.config.profile:
11 return
12
13 - settings_path = files.get_abs_path("agents", self.agent.config.profile, "settings.json")
14 - if files.exists(settings_path):
15 - try:
16 - override_settings_str = files.read_file(settings_path)
17 - override_settings = dirty_json.parse(override_settings_str)
18 -
19 - if isinstance(override_settings, dict):
20 - # Preserve the original memory_subdir unless it's explicitly overridden
21 - current_memory_subdir = self.agent.config.memory_subdir
22 -
23 - new_config = initialize_agent(override_settings=override_settings)
24 -
25 - if (
26 - "agent_memory_subdir" not in override_settings
27 - and current_memory_subdir != "default"
28 - ):
29 - new_config.memory_subdir = current_memory_subdir
30 -
31 - self.agent.config = new_config
32 -
13 + config_files = subagents.get_agent_paths_chain(self.agent, "settings.json", include_default=False)
14 +
15 + settings_override = {}
16 + for settings_path in config_files:
17 + if files.exists(settings_path):
18 + try:
19 + override_settings_str = files.read_file(settings_path)
20 + override_settings = dirty_json.try_parse(override_settings_str)
21 + if isinstance(override_settings, dict):
22 + settings_override.update(override_settings)
23 + else:
24 + raise Exception(
25 + f"Subordinate settings in {settings_path} must be a JSON object."
26 + )
27 + except Exception as e:
28 self.agent.context.log.log(
34 - type="info",
29 + type="error",
30 content=(
36 - "Loaded custom settings for agent "
37 - f"{self.agent.number} with profile '{self.agent.config.profile}'."
31 + f"Error loading subordinate settings from {settings_path} for "
32 + f"profile '{self.agent.config.profile}': {e}"
33 ),
34 )
40 - else:
41 - raise Exception(
42 - f"Subordinate settings in {settings_path} "
43 - "must be a JSON object."
44 - )
35
46 - except Exception as e:
47 - self.agent.context.log.log(
48 - type="error",
49 - content=(
50 - "Error loading subordinate settings for "
51 - f"profile '{self.agent.config.profile}': {e}"
52 - ),
53 - )
36 + if settings_override:
37 + # Preserve the original memory_subdir unless it's explicitly overridden
38 + current_memory_subdir = self.agent.config.memory_subdir
39 + new_config = initialize_agent(override_settings=settings_override)
40 + if (
41 + "agent_memory_subdir" not in settings_override
42 + and current_memory_subdir != "default"
43 + ):
44 + new_config.memory_subdir = current_memory_subdir
45 + self.agent.config = new_config
46 + # self.agent.context.log.log(
47 + # type="info",
48 + # content=(
49 + # "Loaded custom settings for agent "
50 + # f"{self.agent.number} with profile '{self.agent.config.profile}'."
51 + # ),
52 + # )
53 +
python/helpers/extension.py
+30 -24
@@ -1,14 +1,22 @@
1 from abc import abstractmethod
2 from typing import Any
3 -from python.helpers import extract_tools, files
3 +from python.helpers import extract_tools, files
4 from typing import TYPE_CHECKING
5 +
6 if TYPE_CHECKING:
7 from agent import Agent
8
9 +
10 +DEFAULT_EXTENSIONS_FOLDER = "python/extensions"
11 +USER_EXTENSIONS_FOLDER = "usr/extensions"
12 +
13 +_cache: dict[str, list[type["Extension"]]] = {}
14 +
15 +
16 class Extension:
17
18 def __init__(self, agent: "Agent|None", **kwargs):
11 - self.agent: "Agent" = agent # type: ignore < here we ignore the type check as there are currently no extensions without an agent
19 + self.agent: "Agent" = agent # type: ignore < here we ignore the type check as there are currently no extensions without an agent
20 self.kwargs = kwargs
21
22 @abstractmethod
@@ -16,25 +24,26 @@ class Extension:
24 pass
25
26
19 -async def call_extensions(extension_point: str, agent: "Agent|None" = None, **kwargs) -> Any:
20 -
21 - # get default extensions
22 - defaults = await _get_extensions("python/extensions/" + extension_point)
23 - classes = defaults
27 +async def call_extensions(
28 + extension_point: str, agent: "Agent|None" = None, **kwargs
29 +) -> Any:
30 + from python.helpers import projects, subagents
31
25 - # get agent extensions
26 - if agent and agent.config.profile:
27 - agentics = await _get_extensions("agents/" + agent.config.profile + "/extensions/" + extension_point)
28 - if agentics:
29 - # merge them, agentics overwrite defaults
30 - unique = {}
31 - for cls in defaults + agentics:
32 - unique[_get_file_from_module(cls.__module__)] = cls
32 + # search for extension folders in all agent's paths
33 + paths = subagents.get_agent_paths_chain(agent, "extensions", extension_point, default_root="python")
34 + all_exts = [cls for path in paths for cls in _get_extensions(path)]
35
34 - # sort by name
35 - classes = sorted(unique.values(), key=lambda cls: _get_file_from_module(cls.__module__))
36 + # merge: first ocurrence of file name is the override
37 + unique = {}
38 + for cls in all_exts:
39 + file = _get_file_from_module(cls.__module__)
40 + if file not in unique:
41 + unique[file] = cls
42 + classes = sorted(
43 + unique.values(), key=lambda cls: _get_file_from_module(cls.__module__)
44 + )
45
37 - # call extensions
46 + # execute unique extensions
47 for cls in classes:
48 await cls(agent=agent).execute(**kwargs)
49
@@ -42,8 +51,8 @@ async def call_extensions(extension_point: str, agent: "Agent|None" = None, **kw
51 def _get_file_from_module(module_name: str) -> str:
52 return module_name.split(".")[-1]
53
45 -_cache: dict[str, list[type[Extension]]] = {}
46 -async def _get_extensions(folder:str):
54 +
55 +def _get_extensions(folder: str):
56 global _cache
57 folder = files.get_abs_path(folder)
58 if folder in _cache:
@@ -51,10 +60,7 @@ async def _get_extensions(folder:str):
60 else:
61 if not files.exists(folder):
62 return []
54 - classes = extract_tools.load_classes_from_folder(
55 - folder, "*", Extension
56 - )
63 + classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
64 _cache[folder] = classes
65
66 return classes
60 -
python/helpers/files.py
+58 -4
@@ -15,6 +15,7 @@ import importlib.util
15 import inspect
16 import glob
17 import mimetypes
18 +from simpleeval import simple_eval
19
20
21 class VariablesPlugin(ABC):
@@ -138,6 +139,9 @@ def read_prompt_file(
139 variables = load_plugin_variables(_file, _directories, **kwargs) or {} # type: ignore
140 variables.update(kwargs)
141
142 + # evaluate conditions
143 + content = evaluate_text_conditions(content, **variables)
144 +
145 # Replace placeholders with values from kwargs
146 content = replace_placeholders_text(content, **variables)
147
@@ -152,6 +156,53 @@ def read_prompt_file(
156 return content
157
158
159 +def evaluate_text_conditions(_content: str, **kwargs):
160 + # search for {{if ...}} ... {{endif}} blocks and evaluate conditions with nesting support
161 + if_pattern = re.compile(r"{{\s*if\s+(.*?)}}", flags=re.DOTALL)
162 + token_pattern = re.compile(r"{{\s*(if\b.*?|endif)\s*}}", flags=re.DOTALL)
163 +
164 + def _process(text: str) -> str:
165 + m_if = if_pattern.search(text)
166 + if not m_if:
167 + return text
168 +
169 + depth = 1
170 + pos = m_if.end()
171 + while True:
172 + m = token_pattern.search(text, pos)
173 + if not m:
174 + # Unterminated if-block, do not modify text
175 + return text
176 + token = m.group(1)
177 + depth += 1 if token.startswith("if ") else -1
178 + if depth == 0:
179 + break
180 + pos = m.end()
181 +
182 + before = text[: m_if.start()]
183 + condition = m_if.group(1).strip()
184 + inner = text[m_if.end() : m.start()]
185 + after = text[m.end() :]
186 +
187 + try:
188 + result = simple_eval(condition, names=kwargs)
189 + except Exception:
190 + # On evaluation error, do not modify this block
191 + return text
192 +
193 + if result:
194 + # Keep inner content (processed recursively), remove if/endif markers
195 + kept = before + _process(inner)
196 + else:
197 + # Skip entire block, including inner content and markers
198 + kept = before
199 +
200 + # Continue processing the remaining text after this block
201 + return kept + _process(after)
202 +
203 + return _process(_content)
204 +
205 +
206 def read_file(relative_path: str, encoding="utf-8"):
207 # Try to get the absolute path for the file from the original directory or backup directories
208 absolute_path = get_abs_path(relative_path)
@@ -192,8 +243,9 @@ def replace_placeholders_json(_content: str, **kwargs):
243 # Replace placeholders with values from kwargs
244 for key, value in kwargs.items():
245 placeholder = "{{" + key + "}}"
195 - strval = json.dumps(value)
196 - _content = _content.replace(placeholder, strval)
246 + if placeholder in _content:
247 + strval = json.dumps(value)
248 + _content = _content.replace(placeholder, strval)
249 return _content
250
251
@@ -508,7 +560,7 @@ def safe_file_name(filename: str) -> str:
560
561
562 def read_text_files_in_dir(
511 - dir_path: str, max_size: int = 1024 * 1024
563 + dir_path: str, max_size: int = 1024 * 1024, pattern: str = "*"
564 ) -> dict[str, str]:
565
566 abs_path = get_abs_path(dir_path)
@@ -519,7 +571,9 @@ def read_text_files_in_dir(
571 try:
572 if not os.path.isfile(file_path):
573 continue
522 - if os.path.getsize(file_path) > max_size:
574 + if not fnmatch(os.path.basename(file_path), pattern):
575 + continue
576 + if max_size > 0 and os.path.getsize(file_path) > max_size:
577 continue
578 mime, _ = mimetypes.guess_type(file_path)
579 if mime is not None and not mime.startswith("text"):
python/helpers/projects.py
+49 -2
@@ -25,7 +25,9 @@ class FileStructureInjectionSettings(TypedDict):
25 max_lines: int
26 gitignore: str
27
28 -
28 +class SubAgentSettings(TypedDict):
29 + enabled: bool
30 +
31 class BasicProjectData(TypedDict):
32 title: str
33 description: str
@@ -36,13 +38,14 @@ class BasicProjectData(TypedDict):
38 ] # in the future we can add cutom and point to another existing folder
39 file_structure: FileStructureInjectionSettings
40
39 -
41 class EditProjectData(BasicProjectData):
42 name: str
43 instruction_files_count: int
44 knowledge_files_count: int
45 variables: str
46 secrets: str
47 + subagents: dict[str, SubAgentSettings]
48 +
49
50
51 def get_projects_parent_folder():
@@ -128,6 +131,7 @@ def _normalizeEditData(data: EditProjectData):
131 "file_structure",
132 _default_file_structure_settings(),
133 ),
134 + subagents=data.get("subagents", {}),
135 )
136
137
@@ -152,6 +156,7 @@ def update_project(name: str, data: EditProjectData):
156 # save secrets
157 save_project_variables(name, current["variables"])
158 save_project_secrets(name, current["secrets"])
159 + save_project_subagents(name, current["subagents"])
160
161 reactivate_project_in_chats(name)
162 return name
@@ -170,6 +175,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
175 ) # for additional info
176 variables = load_project_variables(name)
177 secrets = load_project_secrets_masked(name)
178 + subagents = load_project_subagents(name)
179 knowledge_files_count = get_knowledge_files_count(name)
180 data = EditProjectData(
181 **data,
@@ -178,6 +184,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
184 knowledge_files_count=knowledge_files_count,
185 variables=variables,
186 secrets=secrets,
187 + subagents=subagents,
188 )
189 data = _normalizeEditData(data)
190 return data
@@ -314,6 +321,46 @@ def save_project_variables(name: str, variables: str):
321 files.write_file(abs_path, variables)
322
323
324 +def load_project_subagents(name: str) -> dict[str, SubAgentSettings]:
325 + try:
326 + abs_path = files.get_abs_path(get_project_meta_folder(name), "agents.json")
327 + data = dirty_json.parse(files.read_file(abs_path))
328 + if isinstance(data, dict):
329 + return _normalize_subagents(data) # type: ignore[arg-type,return-value]
330 + return {}
331 + except Exception:
332 + return {}
333 +
334 +
335 +def save_project_subagents(name: str, subagents_data: dict[str, SubAgentSettings]):
336 + abs_path = files.get_abs_path(get_project_meta_folder(name), "agents.json")
337 + normalized = _normalize_subagents(subagents_data)
338 + content = dirty_json.stringify(normalized)
339 + files.write_file(abs_path, content)
340 +
341 +
342 +def _normalize_subagents(
343 + subagents_data: dict[str, SubAgentSettings]
344 +) -> dict[str, SubAgentSettings]:
345 + from python.helpers import subagents
346 +
347 + agents_dict = subagents.get_agents_dict()
348 +
349 + normalized: dict[str, SubAgentSettings] = {}
350 + for key, value in subagents_data.items():
351 + agent = agents_dict.get(key)
352 + if not agent:
353 + continue
354 +
355 + enabled = bool(value["enabled"])
356 + if agent.enabled == enabled:
357 + continue
358 +
359 + normalized[key] = {"enabled": enabled}
360 +
361 + return normalized
362 +
363 +
364 def load_project_secrets_masked(name: str, merge_with_global=False):
365 from python.helpers import secrets
366
python/helpers/subagents.py new
+329
@@ -0,0 +1,329 @@
1 +from python.helpers import files
2 +from typing import TypedDict, TYPE_CHECKING
3 +from pydantic import BaseModel, model_validator
4 +import json
5 +from typing import Literal
6 +
7 +GLOBAL_DIR = "."
8 +USER_DIR = "usr"
9 +DEFAULT_AGENTS_DIR = "agents"
10 +USER_AGENTS_DIR = "usr/agents"
11 +
12 +type Origin = Literal["default", "user", "project"]
13 +
14 +if TYPE_CHECKING:
15 + from agent import Agent
16 +
17 +
18 +class SubAgentListItem(BaseModel):
19 + name: str = ""
20 + title: str = ""
21 + description: str = ""
22 + context: str = ""
23 + origin: list[Origin] = []
24 + enabled: bool = True
25 +
26 + @model_validator(mode="after")
27 + def post_validator(self):
28 + if self.title == "":
29 + self.title = self.name
30 + return self
31 +
32 +
33 +class SubAgent(SubAgentListItem):
34 + prompts: dict[str, str] = {}
35 +
36 +
37 +def get_agents_list(project_name: str | None = None) -> list[SubAgentListItem]:
38 + return list(get_agents_dict(project_name).values())
39 +
40 +
41 +def get_agents_dict(
42 + project_name: str | None = None,
43 +) -> dict[str, SubAgentListItem]:
44 + def _merge_agent_dicts(
45 + base: dict[str, SubAgentListItem],
46 + overrides: dict[str, SubAgentListItem],
47 + ) -> dict[str, SubAgentListItem]:
48 + merged: dict[str, SubAgentListItem] = dict(base)
49 + for name, override in overrides.items():
50 + base_agent = merged.get(name)
51 + merged[name] = (
52 + _merge_agent_list_items(base_agent, override)
53 + if base_agent
54 + else override
55 + )
56 + return merged
57 +
58 + # load default and custom agents and merge
59 + default_agents = _get_agents_list_from_dir(DEFAULT_AGENTS_DIR, origin="default")
60 + custom_agents = _get_agents_list_from_dir(USER_AGENTS_DIR, origin="user")
61 + merged = _merge_agent_dicts(default_agents, custom_agents)
62 +
63 + # merge with project agents if possible
64 + if project_name:
65 + from python.helpers import projects
66 +
67 + project_agents_dir = projects.get_project_meta_folder(project_name, "agents")
68 + project_agents = _get_agents_list_from_dir(project_agents_dir, origin="project")
69 + merged = _merge_agent_dicts(merged, project_agents)
70 +
71 + return merged
72 +
73 +
74 +def _get_agents_list_from_dir(dir: str, origin: Origin) -> dict[str, SubAgentListItem]:
75 + result: dict[str, SubAgentListItem] = {}
76 + subdirs = files.get_subdirectories(dir)
77 +
78 + for subdir in subdirs:
79 + try:
80 + agent_json = files.read_file(files.get_abs_path(dir, subdir, "agent.json"))
81 + agent_data = SubAgentListItem.model_validate_json(agent_json)
82 + name = agent_data.name or subdir
83 + agent_data.name = name
84 + agent_data.origin = [origin]
85 + result[name] = agent_data
86 + except Exception:
87 + continue
88 +
89 + return result
90 +
91 +
92 +def load_agent_data(name: str, project_name: str | None = None) -> SubAgent:
93 + def _merge_agent(
94 + original: SubAgent | None, override: SubAgent | None = None
95 + ) -> SubAgent | None:
96 + if original and override:
97 + return _merge_agents(original, override)
98 + elif original:
99 + return original
100 + return override
101 +
102 + # load default and user agents and merge
103 + default_agent = _load_agent_data_from_dir(
104 + DEFAULT_AGENTS_DIR, name, origin="default"
105 + )
106 + user_agent = _load_agent_data_from_dir(USER_AGENTS_DIR, name, origin="user")
107 + merged = _merge_agent(default_agent, user_agent)
108 +
109 + # merge with project agent if possible
110 + if project_name:
111 + from python.helpers import projects
112 +
113 + project_agents_dir = projects.get_project_meta_folder(project_name, "agents")
114 + project_agent = _load_agent_data_from_dir(
115 + project_agents_dir, name, origin="project"
116 + )
117 + merged = _merge_agent(merged, project_agent)
118 +
119 + if merged is None:
120 + raise FileNotFoundError(
121 + f"Agent '{name}' not found in default or custom directories"
122 + )
123 +
124 + return merged
125 +
126 +
127 +def save_agent_data(name: str, subagent: SubAgent) -> None:
128 + # write agent.json in custom directory
129 + agent_dir = f"{USER_AGENTS_DIR}/{name}"
130 + agent_json = {
131 + "title": subagent.title,
132 + "description": subagent.description,
133 + "context": subagent.context,
134 + "enabled": subagent.enabled,
135 + }
136 + files.write_file(f"{agent_dir}/agent.json", json.dumps(agent_json, indent=2))
137 +
138 + # replace prompts in custom directory
139 + prompts_dir = f"{agent_dir}/prompts"
140 + # clear existing custom prompts directory (if any)
141 + files.delete_dir(prompts_dir)
142 +
143 + prompts = subagent.prompts or {}
144 + for name, content in prompts.items():
145 + safe_name = files.safe_file_name(name)
146 + if not safe_name.endswith(".md"):
147 + safe_name += ".md"
148 + files.write_file(f"{prompts_dir}/{safe_name}", content)
149 +
150 +
151 +def delete_agent_data(name: str) -> None:
152 + files.delete_dir(f"{USER_AGENTS_DIR}/{name}")
153 +
154 +
155 +def _load_agent_data_from_dir(dir: str, name: str, origin: Origin) -> SubAgent | None:
156 + try:
157 + subagent_json = files.read_file(files.get_abs_path(dir, name, "agent.json"))
158 + subagent = SubAgent.model_validate_json(subagent_json)
159 + except Exception:
160 + # backward compatibility (before agent.json existed)
161 + try:
162 + context_file = files.read_file(files.get_abs_path(dir, name, "_context.md"))
163 + except Exception:
164 + context_file = ""
165 + subagent = SubAgent(
166 + name=name,
167 + title=name,
168 + description="",
169 + context=context_file,
170 + origin=[origin],
171 + prompts={},
172 + )
173 +
174 + # non-stored fields
175 + subagent.name = name
176 + subagent.origin = [origin]
177 +
178 + prompts_dir = f"{dir}/{name}/prompts"
179 + try:
180 + prompts = files.read_text_files_in_dir(prompts_dir, pattern="*.md")
181 + except Exception:
182 + prompts = {}
183 +
184 + subagent.prompts = prompts or {}
185 + return subagent
186 +
187 +
188 +def _merge_agents(base: SubAgent | None, override: SubAgent | None) -> SubAgent | None:
189 + if base is None:
190 + return override
191 + if override is None:
192 + return base
193 +
194 + merged_prompts: dict[str, str] = {}
195 + merged_prompts.update(base.prompts or {})
196 + merged_prompts.update(override.prompts or {})
197 +
198 + return SubAgent(
199 + name=override.name,
200 + title=override.title,
201 + description=override.description,
202 + context=override.context,
203 + origin=_merge_origins(base.origin, override.origin),
204 + prompts=merged_prompts,
205 + )
206 +
207 +
208 +def _merge_agent_list_items(
209 + base: SubAgentListItem, override: SubAgentListItem
210 +) -> SubAgentListItem:
211 + return SubAgentListItem(
212 + name=override.name or base.name,
213 + title=override.title or base.title,
214 + description=override.description or base.description,
215 + context=override.context or base.context,
216 + origin=_merge_origins(base.origin, override.origin),
217 + )
218 +
219 +
220 +def _merge_origins(base: list[Origin], override: list[Origin]) -> list[Origin]:
221 + return base + override
222 +
223 +
224 +def get_default_promp_file_names() -> list[str]:
225 + return files.list_files("prompts", filter="*.md")
226 +
227 +
228 +def get_available_agents_dict(
229 + project_name: str | None,
230 +) -> dict[str, SubAgentListItem]:
231 + # all available agents
232 + all_agents = get_agents_dict()
233 + # filter by project settings
234 + from python.helpers import projects
235 +
236 + project_settings = (
237 + projects.load_project_subagents(project_name) if project_name else {}
238 + )
239 +
240 + filtered_agents: dict[str, SubAgentListItem] = {}
241 + for name, agent in all_agents.items():
242 + if name in project_settings:
243 + agent.enabled = project_settings[name]["enabled"]
244 + if agent.enabled:
245 + filtered_agents[name] = agent
246 + return filtered_agents
247 +
248 +
249 +def get_agent_paths(
250 + agent: "Agent", *subpaths, must_exist_completely: bool = True
251 +) -> list[str]:
252 + """Returns list of possible paths for the given agent and subpaths. Order is from lowest priority (global)."""
253 +
254 + if not agent or not agent.config.profile:
255 + return []
256 + from python.helpers import projects
257 +
258 + project_name = projects.get_context_project_name(agent.context)
259 + return get_agent_profile_paths(
260 + agent.config.profile,
261 + project_name,
262 + *subpaths,
263 + must_exist_completely=must_exist_completely,
264 + )
265 +
266 +
267 +def get_agent_paths_chain(
268 + agent: "Agent|None",
269 + *subpaths,
270 + must_exist_completely: bool = True,
271 + include_project: bool = True,
272 + include_user: bool = True,
273 + include_default: bool = True,
274 + default_root: str = "",
275 +) -> list[str]:
276 + """Returns list of file paths for the given agent and subpaths, searched in order of priority:
277 + project/agents/, usr/agents/, agents/, project/, usr/, default."""
278 + from python.helpers import projects
279 +
280 + if agent and agent.config.profile:
281 + project_name = projects.get_context_project_name(agent.context)
282 + paths = get_agent_profile_paths(
283 + agent.config.profile,
284 + project_name,
285 + *subpaths,
286 + must_exist_completely=must_exist_completely,
287 + )
288 + list.reverse(paths) # reverse for proper priority
289 + else:
290 + paths = []
291 + project_name = ""
292 +
293 + if include_project and project_name:
294 + path = projects.get_project_meta_folder(project_name, *subpaths)
295 + if (not must_exist_completely) or files.exists(path):
296 + paths.append(path)
297 + if include_user:
298 + path = files.get_abs_path(USER_DIR, *subpaths)
299 + if (not must_exist_completely) or files.exists(path):
300 + paths.append(path)
301 + if include_default:
302 + path = files.get_abs_path(default_root, *subpaths)
303 + if (not must_exist_completely) or files.exists(path):
304 + paths.append(path)
305 + return paths
306 +
307 +
308 +def get_agent_profile_paths(
309 + name: str,
310 + project_name: str | None = None,
311 + *subpaths,
312 + must_exist_completely: bool = True,
313 +) -> list[str]:
314 + result = []
315 + check_subpaths = subpaths if must_exist_completely else []
316 +
317 + if files.exists(files.get_abs_path(DEFAULT_AGENTS_DIR, name, *check_subpaths)):
318 + result.append(files.get_abs_path(DEFAULT_AGENTS_DIR, name, *subpaths))
319 + if files.exists(files.get_abs_path(USER_AGENTS_DIR, name, *check_subpaths)):
320 + result.append(files.get_abs_path(USER_AGENTS_DIR, name, *subpaths))
321 + if project_name:
322 + from python.helpers import projects
323 +
324 + project_agent_dir = projects.get_project_meta_folder(
325 + project_name, "agents", name
326 + )
327 + if files.exists(files.get_abs_path(project_agent_dir, *check_subpaths)):
328 + result.append(files.get_abs_path(project_agent_dir, *subpaths))
329 + return result
python/tools/behaviour_adjustment.py
+1 -1
@@ -58,7 +58,7 @@ def get_custom_rules_file(agent: Agent):
58 def read_rules(agent: Agent):
59 rules_file = get_custom_rules_file(agent)
60 if files.exists(rules_file):
61 - rules = files.read_prompt_file(rules_file)
61 + rules = agent.read_prompt(rules_file)
62 return agent.read_prompt("agent.system.behaviour.md", rules=rules)
63 else:
64 rules = agent.read_prompt("agent.system.behaviour_default.md")