Skip to content

Commit b59833b

Browse files
authored
Merge branch 'development' into fix/profile-preservation-bug
2 parents f530fb1 + 601bb56 commit b59833b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1663
-317
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Agent Zero now supports **Projects** – isolated workspaces with their own prom
8181
- The framework does not guide or limit the agent in any way. There are no hard-coded rails that agents have to follow.
8282
- Every prompt, every small message template sent to the agent in its communication loop can be found in the **prompts/** folder and changed.
8383
- Every default tool can be found in the **python/tools/** folder and changed or copied to create new predefined tools.
84+
- **Automated configuration** via `A0_SET_` environment variables for deployment automation and easy setup.
8485

8586
![Prompts](/docs/res/prompts.png)
8687

agent.py

Lines changed: 119 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@
1111
import uuid
1212
import models
1313

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+
)
1624
from python.helpers.print_style import PrintStyle
1725

1826
from langchain_core.prompts import (
@@ -69,9 +77,10 @@ def __init__(
6977
# initialize state
7078
self.name = name
7179
self.config = config
80+
self.data = data or {}
81+
self.output_data = output_data or {}
7282
self.log = log or Log.Log()
7383
self.log.context = self
74-
self.agent0 = agent0 or Agent(0, self.config, self)
7584
self.paused = paused
7685
self.streaming_agent = streaming_agent
7786
self.task: DeferredTask | None = None
@@ -80,10 +89,9 @@ def __init__(
8089
AgentContext._counter += 1
8190
self.no = AgentContext._counter
8291
self.last_message = last_message or datetime.now(timezone.utc)
83-
self.data = data or {}
84-
self.output_data = output_data or {}
85-
8692

93+
# initialize agent at last (context is complete now)
94+
self.agent0 = agent0 or Agent(0, self.config, self)
8795

8896
@staticmethod
8997
def get(id: str):
@@ -100,7 +108,7 @@ def use(id: str):
100108

101109
@staticmethod
102110
def current():
103-
ctxid = context_helper.get_context_data("agent_context_id","")
111+
ctxid = context_helper.get_context_data("agent_context_id", "")
104112
if not ctxid:
105113
return None
106114
return AgentContext.get(ctxid)
@@ -122,7 +130,8 @@ def all():
122130
@staticmethod
123131
def generate_id():
124132
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+
126135
while True:
127136
short_id = generate_short_id()
128137
if short_id not in AgentContext._contexts:
@@ -132,6 +141,7 @@ def generate_short_id():
132141
def get_notification_manager(cls):
133142
if cls._notification_manager is None:
134143
from python.helpers.notification import NotificationManager # type: ignore
144+
135145
cls._notification_manager = NotificationManager()
136146
return cls._notification_manager
137147

@@ -269,7 +279,6 @@ async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True
269279
agent.handle_critical_exception(e)
270280

271281

272-
273282
@dataclass
274283
class AgentConfig:
275284
chat_model: models.ModelConfig
@@ -280,7 +289,9 @@ class AgentConfig:
280289
profile: str = ""
281290
memory_subdir: str = ""
282291
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
284295
code_exec_ssh_enabled: bool = True
285296
code_exec_ssh_addr: str = "localhost"
286297
code_exec_ssh_port: int = 55022
@@ -354,6 +365,7 @@ def __init__(
354365
asyncio.run(self.call_extensions("agent_init"))
355366

356367
async def monologue(self):
368+
error_retries = 0 # counter for critical error retries
357369
while True:
358370
try:
359371
# loop data dictionary to pass to extensions
@@ -380,7 +392,9 @@ async def monologue(self):
380392
prompt = await self.prepare_prompt(loop_data=self.loop_data)
381393

382394
# call before_main_llm_call extensions
383-
await self.call_extensions("before_main_llm_call", loop_data=self.loop_data)
395+
await self.call_extensions(
396+
"before_main_llm_call", loop_data=self.loop_data
397+
)
384398

385399
async def reasoning_callback(chunk: str, full: str):
386400
await self.handle_intervention()
@@ -389,7 +403,9 @@ async def reasoning_callback(chunk: str, full: str):
389403
# Pass chunk and full data to extensions for processing
390404
stream_data = {"chunk": chunk, "full": full}
391405
await self.call_extensions(
392-
"reasoning_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
406+
"reasoning_stream_chunk",
407+
loop_data=self.loop_data,
408+
stream_data=stream_data,
393409
)
394410
# Stream masked chunk after extensions processed it
395411
if stream_data.get("chunk"):
@@ -405,7 +421,9 @@ async def stream_callback(chunk: str, full: str):
405421
# Pass chunk and full data to extensions for processing
406422
stream_data = {"chunk": chunk, "full": full}
407423
await self.call_extensions(
408-
"response_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
424+
"response_stream_chunk",
425+
loop_data=self.loop_data,
426+
stream_data=stream_data,
409427
)
410428
# Stream masked chunk after extensions processed it
411429
if stream_data.get("chunk"):
@@ -453,6 +471,7 @@ async def stream_callback(chunk: str, full: str):
453471

454472
# exceptions inside message loop:
455473
except InterventionException as e:
474+
error_retries = 0 # reset retry counter on user intervention
456475
pass # intervention message has been handled in handle_intervention(), proceed with conversation loop
457476
except RepairableException as e:
458477
# Forward repairable errors to the LLM, maybe it can fix them
@@ -462,8 +481,10 @@ async def stream_callback(chunk: str, full: str):
462481
PrintStyle(font_color="red", padding=True).print(msg["message"])
463482
self.context.log.log(type="error", content=msg["message"])
464483
except Exception as e:
465-
# Other exception kill the loop
466-
self.handle_critical_exception(e)
484+
# Retry critical exceptions before failing
485+
error_retries = await self.retry_critical_exception(
486+
e, error_retries
487+
)
467488

468489
finally:
469490
# call message_loop_end extensions
@@ -473,9 +494,13 @@ async def stream_callback(chunk: str, full: str):
473494

474495
# exceptions outside message loop:
475496
except InterventionException as e:
497+
error_retries = 0 # reset retry counter on user intervention
476498
pass # just start over
477499
except Exception as e:
478-
self.handle_critical_exception(e)
500+
# Retry critical exceptions before failing
501+
error_retries = await self.retry_critical_exception(
502+
e, error_retries
503+
)
479504
finally:
480505
self.context.streaming_agent = None # unset current streamer
481506
# call monologue_end extensions
@@ -532,6 +557,30 @@ async def prepare_prompt(self, loop_data: LoopData) -> list[BaseMessage]:
532557

533558
return full_prompt
534559

560+
async def retry_critical_exception(
561+
self, e: Exception, error_retries: int, delay: int = 3, max_retries: int = 1
562+
) -> int:
563+
if error_retries >= max_retries:
564+
self.handle_critical_exception(e)
565+
566+
error_message = errors.format_error(e)
567+
568+
self.context.log.log(
569+
type="warning", content="Critical error occurred, retrying..."
570+
)
571+
PrintStyle(font_color="orange", padding=True).print(
572+
"Critical error occurred, retrying..."
573+
)
574+
await asyncio.sleep(delay)
575+
agent_facing_error = self.read_prompt(
576+
"fw.msg_critical_error.md", error_message=error_message
577+
)
578+
self.hist_add_warning(message=agent_facing_error)
579+
PrintStyle(font_color="orange", padding=True).print(
580+
agent_facing_error
581+
)
582+
return error_retries + 1
583+
535584
def handle_critical_exception(self, exception: Exception):
536585
if isinstance(exception, HandledException):
537586
raise exception # Re-raise the exception to kill the loop
@@ -570,27 +619,15 @@ async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
570619
return system_prompt
571620

572621
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)
622+
dirs = subagents.get_paths(self, "prompts")
579623
prompt = files.parse_file(
580-
_prompt_file, _directories=dirs, **kwargs
624+
_prompt_file, _directories=dirs, _agent=self, **kwargs
581625
)
582626
return prompt
583627

584628
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-
)
629+
dirs = subagents.get_paths(self, "prompts")
630+
prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
594631
prompt = files.remove_code_fences(prompt)
595632
return prompt
596633

@@ -606,8 +643,12 @@ def hist_add_message(
606643
self.last_message = datetime.now(timezone.utc)
607644
# Allow extensions to process content before adding to history
608645
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)
646+
asyncio.run(
647+
self.call_extensions("hist_add_before", content_data=content_data, ai=ai)
648+
)
649+
return self.history.add_message(
650+
ai=ai, content=content_data["content"], tokens=tokens
651+
)
611652

612653
def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
613654
self.history.new_topic() # user message starts a new topic in history
@@ -720,7 +761,9 @@ async def stream_callback(chunk: str, total: str):
720761
system_message=call_data["system"],
721762
user_message=call_data["message"],
722763
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,
764+
rate_limiter_callback=(
765+
self.rate_limiter_callback if not call_data["background"] else None
766+
),
724767
)
725768

726769
return response
@@ -742,7 +785,9 @@ async def call_chat_model(
742785
messages=messages,
743786
reasoning_callback=reasoning_callback,
744787
response_callback=response_callback,
745-
rate_limiter_callback=self.rate_limiter_callback if not background else None,
788+
rate_limiter_callback=(
789+
self.rate_limiter_callback if not background else None
790+
),
746791
)
747792

748793
return response, reasoning
@@ -817,11 +862,15 @@ async def process_tools(self, msg: str):
817862
# Fallback to local get_tool if MCP tool was not found or MCP lookup failed
818863
if not tool:
819864
tool = self.get_tool(
820-
name=tool_name, method=tool_method, args=tool_args, message=msg, loop_data=self.loop_data
865+
name=tool_name,
866+
method=tool_method,
867+
args=tool_args,
868+
message=msg,
869+
loop_data=self.loop_data,
821870
)
822871

823872
if tool:
824-
self.loop_data.current_tool = tool # type: ignore
873+
self.loop_data.current_tool = tool # type: ignore
825874
try:
826875
await self.handle_intervention()
827876

@@ -830,14 +879,20 @@ async def process_tools(self, msg: str):
830879
await self.handle_intervention()
831880

832881
# Allow extensions to preprocess tool arguments
833-
await self.call_extensions("tool_execute_before", tool_args=tool_args or {}, tool_name=tool_name)
882+
await self.call_extensions(
883+
"tool_execute_before",
884+
tool_args=tool_args or {},
885+
tool_name=tool_name,
886+
)
834887

835888
response = await tool.execute(**tool_args)
836889
await self.handle_intervention()
837890

838891
# Allow extensions to postprocess tool response
839-
await self.call_extensions("tool_execute_after", response=response, tool_name=tool_name)
840-
892+
await self.call_extensions(
893+
"tool_execute_after", response=response, tool_name=tool_name
894+
)
895+
841896
await tool.after_execution(response)
842897
await self.handle_intervention()
843898

@@ -889,34 +944,40 @@ async def handle_response_stream(self, stream: str):
889944
pass
890945

891946
def get_tool(
892-
self, name: str, method: str | None, args: dict, message: str, loop_data: LoopData | None, **kwargs
947+
self,
948+
name: str,
949+
method: str | None,
950+
args: dict,
951+
message: str,
952+
loop_data: LoopData | None,
953+
**kwargs,
893954
):
894955
from python.tools.unknown import Unknown
895956
from python.helpers.tool import Tool
896957

897958
classes = []
898959

899-
# try agent tools first
900-
if self.config.profile:
960+
# search for tools in agent's folder hierarchy
961+
paths = subagents.get_paths(self, "tools", name + ".py", default_root="python")
962+
for path in paths:
901963
try:
902-
classes = extract_tools.load_classes_from_file(
903-
"agents/" + self.config.profile + "/tools/" + name + ".py", Tool # type: ignore[arg-type]
904-
)
964+
classes = extract_tools.load_classes_from_file(path, Tool) # type: ignore[arg-type]
965+
break
905966
except Exception:
906-
pass
967+
continue
907968

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
916969
tool_class = classes[0] if classes else Unknown
917970
return tool_class(
918-
agent=self, name=name, method=method, args=args, message=message, loop_data=loop_data, **kwargs
971+
agent=self,
972+
name=name,
973+
method=method,
974+
args=args,
975+
message=message,
976+
loop_data=loop_data,
977+
**kwargs,
919978
)
920979

921980
async def call_extensions(self, extension_point: str, **kwargs) -> Any:
922-
return await call_extensions(extension_point=extension_point, agent=self, **kwargs)
981+
return await call_extensions(
982+
extension_point=extension_point, agent=self, **kwargs
983+
)

agents/agent0/agent.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)