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")