Merge upstream/development into feat-agent-zero-skills-upgrade

Accept upstream's architectural refactors:
- Settings UI sections moved from Python to frontend components
- User data consolidated under /usr directory
- Inline settings modal replaced by stacked modal system
- settings.js removed (moved to component stores)

Conflicts resolved by accepting upstream for all 5 files.
Our skills/backup features will be re-implemented using the new architecture.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
TerminallyLazy 2026-01-31 02:58:07 -05:00
commit 95de41a99e
175 changed files with 14473 additions and 8675 deletions

View file

@ -50,7 +50,7 @@ class ApiFilesGet(ApiHandler):
if path.startswith("/a0/tmp/uploads/"):
# Internal path - convert to external
filename = path.replace("/a0/tmp/uploads/", "")
external_path = files.get_abs_path("tmp/uploads", filename)
external_path = files.get_abs_path("usr/uploads", filename)
filename = os.path.basename(external_path)
elif path.startswith("/a0/"):
# Other internal Agent Zero paths

View file

@ -6,7 +6,7 @@ from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files, projects
from python.helpers.print_style import PrintStyle
from python.helpers.projects import activate_project
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
from initialize import initialize_agent
import threading
@ -48,8 +48,8 @@ class ApiMessage(ApiHandler):
# Handle attachments (base64 encoded)
attachment_paths = []
if attachments:
upload_folder_int = "/a0/tmp/uploads"
upload_folder_ext = files.get_abs_path("tmp/uploads")
upload_folder_int = "/a0/usr/uploads"
upload_folder_ext = files.get_abs_path("usr/uploads")
os.makedirs(upload_folder_ext, exist_ok=True)
for attachment in attachments:
@ -57,9 +57,9 @@ class ApiMessage(ApiHandler):
continue
try:
filename = secure_filename(attachment["filename"])
filename = safe_filename(attachment["filename"])
if not filename:
continue
raise ValueError("Invalid filename")
# Decode base64 content
file_content = base64.b64decode(attachment["base64"])
@ -136,7 +136,7 @@ class ApiMessage(ApiHandler):
# Add user message to chat history so it's visible in the UI
context.log.log(
type="user",
heading="User message",
heading="",
content=message,
kvps={"attachments": attachment_filenames},
)

22
python/api/banners.py Normal file
View file

@ -0,0 +1,22 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers.extension import call_extensions
class GetBanners(ApiHandler):
"""
API endpoint for Welcome Screen banners.
Add checks as extension scripts in python/extensions/banners/ or usr/extensions/banners/
"""
async def process(self, input: dict, request: Request) -> dict | Response:
banners = input.get("banners", [])
frontend_context = input.get("context", {})
# Banners array passed by reference - extensions append directly to it
await call_extensions("banners", agent=None, banners=banners, frontend_context=frontend_context)
return {"banners": banners}
@classmethod
def get_methods(cls) -> list[str]:
return ["POST"]

View file

@ -17,13 +17,14 @@ class CreateChat(ApiHandler):
new_context = self.use_context(new_ctxid)
# copy selected data from current to new context
if current_context:
current_data_1 = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
if current_data_1:
new_context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_1)
current_data_2 = current_context.get_output_data(projects.CONTEXT_DATA_KEY_PROJECT)
if current_data_2:
new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
# do not create new chats in the same project anymore, it can be annoying
# if current_context:
# current_data_1 = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
# if current_data_1:
# new_context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_1)
# current_data_2 = current_context.get_output_data(projects.CONTEXT_DATA_KEY_PROJECT)
# if current_data_2:
# new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
return {
"ok": True,

View file

@ -1,7 +1,6 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files, memory, notification, projects, notification, runtime
import os
from werkzeug.utils import secure_filename
class GetChatFilesPath(ApiHandler):

View file

@ -70,7 +70,6 @@ class GetCsrfToken(ApiHandler):
)
return {"ok": match, "origin": origin, "allowed_origins": allowed_origins}
def get_origin_from_request(self, request: Request):
# get from origin
r = request.headers.get("Origin") or request.environ.get("HTTP_ORIGIN")
@ -93,7 +92,9 @@ class GetCsrfToken(ApiHandler):
# get the allowed origins from the environment
allowed_origins = [
origin.strip()
for origin in (dotenv.get_dotenv_value(ALLOWED_ORIGINS_KEY) or "").split(",")
for origin in (dotenv.get_dotenv_value(ALLOWED_ORIGINS_KEY) or "").split(
","
)
if origin.strip()
]
@ -114,12 +115,19 @@ class GetCsrfToken(ApiHandler):
return allowed_origins
def get_default_allowed_origins(self) -> list[str]:
return ["*://localhost:*", "*://127.0.0.1:*", "*://0.0.0.0:*"]
return [
"*://localhost",
"*://localhost:*",
"*://127.0.0.1",
"*://127.0.0.1:*",
"*://0.0.0.0",
"*://0.0.0.0:*",
]
def initialize_allowed_origins(self, request: Request):
"""
If A0 is hosted on a server, add the first visit origin to ALLOWED_ORIGINS.
This simplifies deployment process as users can access their new instance without
This simplifies deployment process as users can access their new instance without
additional setup while keeping it secure.
"""
# dotenv value is already set, do nothing
@ -144,5 +152,3 @@ class GetCsrfToken(ApiHandler):
# if not, add it to the allowed origins
allowed_origins.append(req_origin)
dotenv.save_dotenv_value(ALLOWED_ORIGINS_KEY, ",".join(allowed_origins))

View file

@ -8,22 +8,25 @@ from python.api import get_work_dir_files
class DeleteWorkDirFile(ApiHandler):
async def process(self, input: Input, request: Request) -> Output:
file_path = input.get("path", "")
if not file_path.startswith("/"):
file_path = f"/{file_path}"
try:
file_path = input.get("path", "")
if not file_path.startswith("/"):
file_path = f"/{file_path}"
current_path = input.get("currentPath", "")
current_path = input.get("currentPath", "")
# browser = FileBrowser()
res = await runtime.call_development_function(delete_file, file_path)
# browser = FileBrowser()
res = await runtime.call_development_function(delete_file, file_path)
if res:
# Get updated file list
# result = browser.get_files(current_path)
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
return {"data": result}
else:
raise Exception("File not found or could not be deleted")
if res:
# Get updated file list
# result = browser.get_files(current_path)
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
return {"data": result}
else:
return {"error": "File not found or could not be deleted"}
except Exception as e:
return {"error": str(e)}
async def delete_file(file_path: str):

View file

@ -0,0 +1,84 @@
import mimetypes
import os
from python.helpers.api import ApiHandler, Input, Output, Request
from python.helpers.file_browser import FileBrowser
from python.helpers import runtime, files
MAX_EDIT_FILE_SIZE = 1024 * 1024
BINARY_SAMPLE_SIZE = 10 * 1024
class EditWorkDirFile(ApiHandler):
@classmethod
def get_methods(cls):
return ["GET", "POST"]
async def process(self, input: Input, request: Request) -> Output:
try:
if request.method == "GET":
file_path = request.args.get("path", "")
if not file_path:
return {"error": "Path is required"}
if not file_path.startswith("/"):
file_path = f"/{file_path}"
data = await runtime.call_development_function(load_file, file_path)
return {"data": data}
file_path = input.get("path", "")
if not file_path:
return {"error": "Path is required"}
if not file_path.startswith("/"):
file_path = f"/{file_path}"
content = input.get("content", "")
if not isinstance(content, str):
return {"error": "Content must be a string"}
content_size = len(content.encode("utf-8"))
if content_size > MAX_EDIT_FILE_SIZE:
return {"error": "File exceeds 1 MB and cannot be edited"}
res = await runtime.call_development_function(save_file, file_path, content)
if not res:
return {"error": "Failed to save file"}
return {"ok": True}
except Exception as e:
return {"error": str(e)}
async def load_file(file_path: str) -> dict:
browser = FileBrowser()
full_path = browser.get_full_path(file_path)
if os.path.isdir(full_path):
raise Exception("Path points to a directory")
size = os.path.getsize(full_path)
if size > MAX_EDIT_FILE_SIZE:
raise Exception("File exceeds 1 MB and cannot be edited")
# Binary detection: only sample the first ~10KB (per backend rules)
if files.is_probably_binary_file(full_path, sample_size=BINARY_SAMPLE_SIZE):
raise Exception("Binary file detected; editing is not supported")
mime_type, _ = mimetypes.guess_type(full_path)
try:
with open(full_path, "r", encoding="utf-8", errors="strict") as file:
content = file.read()
except UnicodeDecodeError:
raise Exception("Unable to decode file as UTF-8; editing is not supported")
return {
"path": file_path,
"name": os.path.basename(full_path),
"mime_type": mime_type or "text/plain",
"content": content,
}
def save_file(file_path: str, content: str) -> bool:
browser = FileBrowser()
return browser.save_text_file(file_path, content)

View file

@ -23,20 +23,21 @@ class ImageGet(ApiHandler):
if not path:
raise ValueError("No path provided")
# no real need to check, we have the extension filter in place
# check if path is within base directory
if runtime.is_development():
in_base = files.is_in_base_dir(files.fix_dev_path(path))
else:
in_base = files.is_in_base_dir(path)
if not in_base:
raise ValueError("Path is outside of allowed directory")
# if runtime.is_development():
# in_base = files.is_in_base_dir(files.fix_dev_path(path))
# else:
# in_base = files.is_in_base_dir(path)
# if not in_base and not files.is_in_dir(path, "/root"):
# raise ValueError("Path is outside of allowed directory")
# get file extension and info
file_ext = os.path.splitext(path)[1].lower()
filename = os.path.basename(path)
# list of allowed image extensions
image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".svgz"]
# # If metadata is requested, return file information
# if metadata:

View file

@ -1,7 +1,7 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files, memory
import os
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
class ImportKnowledge(ApiHandler):
@ -32,7 +32,9 @@ class ImportKnowledge(ApiHandler):
for file in file_list:
if file and file.filename:
filename = secure_filename(file.filename) # type: ignore
filename = safe_filename(file.filename)
if not filename:
continue
file.save(os.path.join(KNOWLEDGE_FOLDER, filename))
saved_filenames.append(filename)

View file

@ -1,7 +1,6 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files, memory, notification, projects, notification
import os
from werkzeug.utils import secure_filename
class ReindexKnowledge(ApiHandler):

View file

@ -1,11 +1,10 @@
from agent import AgentContext, UserMessage
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files, extension
from python.helpers import files, extension, message_queue as mq
import os
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
from python.helpers.defer import DeferredTask
from python.helpers.print_style import PrintStyle
class Message(ApiHandler):
@ -29,15 +28,17 @@ class Message(ApiHandler):
attachments = request.files.getlist("attachments")
attachment_paths = []
upload_folder_int = "/a0/tmp/uploads"
upload_folder_ext = files.get_abs_path("tmp/uploads") # for development environment
upload_folder_int = "/a0/usr/uploads"
upload_folder_ext = files.get_abs_path("usr/uploads") # for development environment
if attachments:
os.makedirs(upload_folder_ext, exist_ok=True)
for attachment in attachments:
if attachment.filename is None:
continue
filename = secure_filename(attachment.filename)
filename = safe_filename(attachment.filename)
if not filename:
continue
save_path = files.get_abs_path(upload_folder_ext, filename)
attachment.save(save_path)
attachment_paths.append(os.path.join(upload_folder_int, filename))
@ -64,30 +65,7 @@ class Message(ApiHandler):
# Store attachments in agent data
# context.agent0.set_data("attachments", attachment_paths)
# Prepare attachment filenames for logging
attachment_filenames = (
[os.path.basename(path) for path in attachment_paths]
if attachment_paths
else []
)
# Print to console and log
PrintStyle(
background_color="#6C3483", font_color="white", bold=True, padding=True
).print(f"User message:")
PrintStyle(font_color="white", padding=False).print(f"> {message}")
if attachment_filenames:
PrintStyle(font_color="white", padding=False).print("Attachments:")
for filename in attachment_filenames:
PrintStyle(font_color="white", padding=False).print(f"- {filename}")
# Log the message with message_id and attachments
context.log.log(
type="user",
heading="User message",
content=message,
kvps={"attachments": attachment_filenames},
id=message_id,
)
# Log to console and UI using helper function
mq.log_user_message(context, message, attachment_paths, message_id)
return context.communicate(UserMessage(message, attachment_paths)), context

View file

@ -0,0 +1,21 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import message_queue as mq
from agent import AgentContext
class MessageQueueAdd(ApiHandler):
"""Add a message to the queue."""
async def process(self, input: dict, request: Request) -> dict | Response:
context = AgentContext.get(input.get("context", ""))
if not context:
return Response("Context not found", status=404)
text = input.get("text", "").strip()
attachments = input.get("attachments", []) # filenames from /upload API
if not text and not attachments:
return Response("Empty message", status=400)
item = mq.add(context, text, attachments)
return {"ok": True, "item_id": item["id"], "queue_length": len(mq.get_queue(context))}

View file

@ -0,0 +1,16 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import message_queue as mq
from agent import AgentContext
class MessageQueueRemove(ApiHandler):
"""Remove message(s) from queue."""
async def process(self, input: dict, request: Request) -> dict | Response:
context = AgentContext.get(input.get("context", ""))
if not context:
return Response("Context not found", status=404)
item_id = input.get("item_id") # None means clear all
remaining = mq.remove(context, item_id)
return {"ok": True, "remaining": remaining}

View file

@ -0,0 +1,30 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import message_queue as mq
from agent import AgentContext
class MessageQueueSend(ApiHandler):
"""Send queued message(s) immediately."""
async def process(self, input: dict, request: Request) -> dict | Response:
context = AgentContext.get(input.get("context", ""))
if not context:
return Response("Context not found", status=404)
if not mq.has_queue(context):
return {"ok": True, "message": "Queue empty"}
item_id = input.get("item_id")
send_all = input.get("send_all", False)
if send_all:
count = mq.send_all_aggregated(context)
return {"ok": True, "sent_count": count}
# Send single item
item = mq.pop_item(context, item_id) if item_id else mq.pop_first(context)
if not item:
return Response("Item not found", status=404)
mq.send_message(context, item)
return {"ok": True, "sent_item_id": item["id"]}

View file

@ -122,4 +122,6 @@ class Poll(ApiHandler):
"notifications": notifications,
"notifications_guid": notification_manager.guid,
"notifications_version": len(notification_manager.updates),
"message_queue": context.output_data.get("message_queue", []) if context else [],
"running": context.is_running() if context else False,
}

View file

@ -0,0 +1,54 @@
from python.helpers.api import ApiHandler, Input, Output, Request
from python.helpers.file_browser import FileBrowser
from python.helpers import runtime
from python.api import get_work_dir_files
class RenameWorkDirFile(ApiHandler):
async def process(self, input: Input, request: Request) -> Output:
try:
action = input.get("action", "rename")
new_name = (input.get("newName", "") or "").strip()
if not new_name:
return {"error": "New name is required"}
current_path = input.get("currentPath", "")
if action == "create-folder":
parent_path = input.get("parentPath", current_path)
if not parent_path:
return {"error": "Parent path is required"}
res = await runtime.call_development_function(
create_folder, parent_path, new_name
)
else:
file_path = input.get("path", "")
if not file_path:
return {"error": "Path is required"}
if not file_path.startswith("/"):
file_path = f"/{file_path}"
res = await runtime.call_development_function(
rename_item, file_path, new_name
)
if res:
result = await runtime.call_development_function(
get_work_dir_files.get_files, current_path
)
return {"data": result}
error_msg = "Failed to create folder" if action == "create-folder" else "Rename failed"
return {"error": error_msg}
except Exception as e:
return {"error": str(e)}
async def rename_item(file_path: str, new_name: str) -> bool:
browser = FileBrowser()
return browser.rename_item(file_path, new_name)
async def create_folder(parent_path: str, folder_name: str) -> bool:
browser = FileBrowser()
return browser.create_folder(parent_path, folder_name)

View file

@ -4,8 +4,9 @@ from python.helpers import settings
class GetSettings(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
set = settings.convert_out(settings.get_settings())
return {"settings": set}
backend = settings.get_settings()
out = settings.convert_out(backend)
return dict(out)
@classmethod
def get_methods(cls) -> list[str]:

View file

@ -7,6 +7,8 @@ from typing import Any
class SetSettings(ApiHandler):
async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
set = settings.convert_in(input)
set = settings.set_settings(set)
return {"settings": set}
frontend = input.get("settings", input)
backend = settings.convert_in(settings.Settings(**frontend))
backend = settings.set_settings(backend)
out = settings.convert_out(backend)
return dict(out)

View file

@ -18,16 +18,19 @@ async def process(input: dict) -> dict | Response:
port = runtime.get_web_ui_port()
provider = input.get("provider", "serveo") # Default to serveo
tunnel_url = tunnel_manager.start_tunnel(port, provider)
if tunnel_url is None:
# Add a little delay and check again - tunnel might be starting
import time
time.sleep(2)
tunnel_url = tunnel_manager.get_tunnel_url()
error = tunnel_manager.get_last_error()
if error:
return {
"success": False,
"tunnel_url": None,
"message": error,
"notifications": tunnel_manager.get_notifications()
}
return {
"success": tunnel_url is not None,
"tunnel_url": tunnel_url,
"message": "Tunnel creation in progress" if tunnel_url is None else "Tunnel created successfully"
"notifications": tunnel_manager.get_notifications()
}
elif action == "stop":
@ -41,9 +44,17 @@ async def process(input: dict) -> dict | Response:
"is_running": tunnel_manager.is_running
}
elif action == "notifications":
return {
"success": True,
"notifications": tunnel_manager.get_notifications(),
"tunnel_url": tunnel_manager.get_tunnel_url(),
"is_running": tunnel_manager.is_running
}
return {
"success": False,
"error": "Invalid action. Use 'create', 'stop', or 'get'."
"error": "Invalid action. Use 'create', 'stop', 'get', or 'notifications'."
}
def stop():

View file

@ -1,6 +1,6 @@
from python.helpers.api import ApiHandler, Request, Response
from python.helpers import files
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
class UploadFile(ApiHandler):
@ -13,8 +13,12 @@ class UploadFile(ApiHandler):
for file in file_list:
if file and self.allowed_file(file.filename): # Check file type
filename = secure_filename(file.filename) # type: ignore
file.save(files.get_abs_path("tmp/upload", filename))
if not file.filename:
continue
filename = safe_filename(file.filename)
if not filename:
continue
file.save(files.get_abs_path("usr/uploads", filename))
saved_filenames.append(filename)
return {"filenames": saved_filenames} # Return saved filenames

View file

@ -35,7 +35,6 @@ class InitialMessage(Extension):
# Add to log (green bubble) for immediate UI display
self.agent.context.log.log(
type="response",
heading=f"{self.agent.agent_name}: Welcome",
content=initial_message_text,
finished=True,
update_progress="none",

View file

@ -0,0 +1,63 @@
from python.helpers.extension import Extension
from python.helpers import dotenv
import re
class UnsecuredConnectionCheck(Extension):
"""Check: non-local without credentials, or credentials over non-HTTPS."""
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
hostname = frontend_context.get("hostname", "")
protocol = frontend_context.get("protocol", "")
auth_login = dotenv.get_dotenv_value(dotenv.KEY_AUTH_LOGIN, "")
auth_password = dotenv.get_dotenv_value(dotenv.KEY_AUTH_PASSWORD, "")
has_credentials = bool(auth_login and auth_login.strip() and auth_password and auth_password.strip())
is_local = self._is_localhost(hostname)
is_https = protocol == "https:"
if not is_local and not has_credentials:
banners.append({
"id": "unsecured-connection",
"type": "warning",
"priority": 80,
"title": "Unsecured Connection",
"html": """You are accessing Agent Zero from a non-local address without authentication.
<a href="#" onclick="document.getElementById('settings').click(); return false;">
Configure credentials</a> in Settings External Services Authentication.""",
"dismissible": True,
"source": "backend"
})
if has_credentials and not is_local and not is_https:
banners.append({
"id": "credentials-unencrypted",
"type": "warning",
"priority": 90,
"title": "Credentials May Be Sent Unencrypted",
"html": """Your connection is not using HTTPS. Login credentials may be transmitted in plain text.
Consider using HTTPS or a secure tunnel.""",
"dismissible": True,
"source": "backend"
})
def _is_localhost(self, hostname: str) -> bool:
local_patterns = ["localhost", "127.0.0.1", "::1", "0.0.0.0"]
if hostname in local_patterns:
return True
# RFC1918 private ranges
if re.match(r"^192\.168\.\d{1,3}\.\d{1,3}$", hostname):
return True
if re.match(r"^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$", hostname):
return True
if re.match(r"^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$", hostname):
return True
# .local domains
if hostname.endswith(".local"):
return True
return False

View file

@ -0,0 +1,64 @@
from python.helpers.extension import Extension
from python.helpers import settings as settings_helper
import models
class MissingApiKeyCheck(Extension):
"""Check if API keys are configured for selected model providers."""
LOCAL_PROVIDERS = ["ollama", "lm_studio"]
LOCAL_EMBEDDING = ["huggingface"]
MODEL_TYPE_NAMES = {
"chat": "Chat Model",
"utility": "Utility Model",
"browser": "Web Browser Model",
"embedding": "Embedding Model",
}
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
current_settings = settings_helper.get_settings()
model_providers = {
"chat": current_settings.get("chat_model_provider", ""),
"utility": current_settings.get("util_model_provider", ""),
"browser": current_settings.get("browser_model_provider", ""),
"embedding": current_settings.get("embed_model_provider", ""),
}
missing_providers = []
for model_type, provider in model_providers.items():
if not provider:
continue
provider_lower = provider.lower()
if provider_lower in self.LOCAL_PROVIDERS:
continue
if model_type == "embedding" and provider_lower in self.LOCAL_EMBEDDING:
continue
api_key = models.get_api_key(provider_lower)
if not (api_key and api_key.strip() and api_key != "None"):
missing_providers.append({
"model_type": self.MODEL_TYPE_NAMES.get(model_type, model_type),
"provider": provider,
})
if not missing_providers:
return
model_list = ", ".join(
f"{p['model_type']} ({p['provider']})" for p in missing_providers
)
banners.append({
"id": "missing-api-key",
"type": "error",
"priority": 100,
"title": "Missing API Key",
"html": f"""No API key configured for: {model_list}.
Agent Zero will not be able to function properly.
<a href="#" onclick="document.getElementById('settings').click(); return false;">
Add your API key</a> in Settings External Services API Keys.""",
"dismissible": False,
"source": "backend"
})

View file

@ -19,8 +19,10 @@ class LogForStream(Extension):
)
)
def build_heading(agent, text: str):
return f"icon://network_intelligence {agent.agent_name}: {text}"
def build_heading(agent, text: str, icon: str = "network_intelligence"):
# Include agent identifier for all agents (A0:, A1:, A2:, etc.)
agent_prefix = f"{agent.agent_name}: "
return f"{agent_prefix}{text}"
def build_default_heading(agent):
return build_heading(agent, "Generating...")
return build_heading(agent, "Calling LLM...")

View file

@ -89,7 +89,7 @@ class RecallMemories(Extension):
except Exception as e:
err = errors.format_error(e)
self.agent.context.log.log(
type="error", heading="Recall memories extension error:", content=err
type="warning", heading="Recall memories extension error:", content=err
)
query = ""
@ -180,7 +180,7 @@ class RecallMemories(Extension):
except Exception as e:
err = errors.format_error(e)
self.agent.context.log.log(
type="error", heading="Failed to filter relevant memories", content=err
type="warning", heading="Failed to filter relevant memories", content=err
)
filter_inds = []

View file

@ -114,7 +114,6 @@ class MemorizeMemories(Extension):
# memory_log = self.agent.context.log.log(
# type="util",
# heading=f"Processing memory fragment: {txt[:50]}...",
# temp=False,
# update_progress="none" # Don't affect status bar
# )
@ -133,7 +132,6 @@ class MemorizeMemories(Extension):
memory_log.update(
result="Fragment processed successfully",
heading=f"Memory fragment completed: {txt[:50]}...",
temp=False, # Show completion message
update_progress="none" # Show briefly then disappear
)
else:
@ -141,7 +139,6 @@ class MemorizeMemories(Extension):
memory_log.update(
result="Fragment processing failed",
heading=f"Memory fragment failed: {txt[:50]}...",
temp=False, # Show completion message
update_progress="none" # Show briefly then disappear
)
total_processed += 1

View file

@ -121,7 +121,6 @@ class MemorizeSolutions(Extension):
# solution_log = self.agent.context.log.log(
# type="util",
# heading=f"Processing solution: {txt[:50]}...",
# temp=False,
# update_progress="none" # Don't affect status bar
# )
@ -140,7 +139,6 @@ class MemorizeSolutions(Extension):
solution_log.update(
result="Solution processed successfully",
heading=f"Solution completed: {txt[:50]}...",
temp=False, # Show completion message
update_progress="none" # Show briefly then disappear
)
else:
@ -148,7 +146,6 @@ class MemorizeSolutions(Extension):
solution_log.update(
result="Solution processing failed",
heading=f"Solution failed: {txt[:50]}...",
temp=False, # Show completion message
update_progress="none" # Show briefly then disappear
)
total_processed += 1

View file

@ -0,0 +1,34 @@
import asyncio
from python.helpers.extension import Extension
from python.helpers import message_queue as mq
from agent import AgentContext, Agent, LoopData
class ProcessQueue(Extension):
"""Process queued messages after monologue ends."""
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
# Only process for agent0 (main agent)
if self.agent.number != 0:
return
context = self.agent.context
# Check if there are queued messages
if mq.has_queue(context):
# Schedule delayed task to send next queued message
# This allows current monologue to fully complete first
asyncio.create_task(self._delayed_send(context))
async def _delayed_send(self, context: AgentContext):
"""Wait for task to complete, then send next queued message."""
# Wait for current task to finish, but no more than 1 minute to prevent hanging tasks
total_wait = 0
while context.is_running() and total_wait < 60:
await asyncio.sleep(0.1)
total_wait += 0.1
# Send next queued message if task is not running
if not context.is_running():
mq.send_next(context)

View file

@ -12,8 +12,10 @@ class LogFromStream(Extension):
async def execute(self, loop_data: LoopData = LoopData(), text: str = "", **kwargs):
# thought length indicator
pipes = "|" * math.ceil(math.sqrt(len(text)))
heading = build_heading(self.agent, f"Reasoning.. {pipes}")
length = f"({len(text)})" if text else ""
pipes = "|" * math.ceil(math.sqrt(len(text))/2)
heading = build_heading(self.agent, f"Reasoning... {pipes}")
step = f"Reasoning... {length}"
# create log message and store it in loop data temporary params
if "log_item_generating" not in loop_data.params_temporary:
@ -21,9 +23,10 @@ class LogFromStream(Extension):
self.agent.context.log.log(
type="agent",
heading=heading,
step=step
)
)
# update log message
log_item = loop_data.params_temporary["log_item_generating"]
log_item.update(heading=heading, reasoning=text)
log_item.update(heading=heading, reasoning=text, step=step)

View file

@ -22,12 +22,13 @@ class LogFromStream(Extension):
if "headline" in parsed:
heading = build_heading(self.agent, parsed['headline'])
elif "tool_name" in parsed:
heading = build_heading(self.agent, f"Using tool {parsed['tool_name']}") # if the llm skipped headline
heading = build_heading(self.agent, f"Using {parsed['tool_name']}") # if the llm skipped headline
elif "thoughts" in parsed:
# thought length indicator
thoughts = "\n".join(parsed["thoughts"])
pipes = "|" * math.ceil(math.sqrt(len(thoughts)))
heading = build_heading(self.agent, f"Thinking... {pipes}")
length = "|" * math.ceil(math.sqrt(len(text))/2)
heading = build_heading(self.agent, f"Thinking... {length}")
else:
heading = build_heading(self.agent, "Receiving...")
# create log message and store it in loop data temporary params
if "log_item_generating" not in loop_data.params_temporary:
@ -45,7 +46,25 @@ class LogFromStream(Extension):
kvps = {}
if log_item.kvps is not None and "reasoning" in log_item.kvps:
kvps["reasoning"] = log_item.kvps["reasoning"]
# step description for UI - using tool XY, writing Python code, etc.
if parsed is not None and "tool_name" in parsed and parsed["tool_name"]:
kvps["step"] = f"Using {parsed['tool_name']}..." # using tool XY
if parsed["tool_name"]=="code_execution_tool":
if "tool_args" in parsed and "runtime" in parsed["tool_args"]:
length = ""
if "code" in parsed["tool_args"]:
length = f"({len(parsed['tool_args']['code'])})"
kvps["step"] = f"Writing code... {length}"
if parsed["tool_args"]["runtime"] == "python":
kvps["step"] = f"Writing Python code... {length}"
elif parsed["tool_args"]["runtime"] == "nodejs":
kvps["step"] = f"Writing Node.js code... {length}"
elif parsed["tool_args"]["runtime"] == "terminal":
kvps["step"] = f"Writing terminal command... {length}"
kvps.update(parsed)
# update the log item
log_item.update(heading=heading, content=text, kvps=kvps)

View file

@ -0,0 +1,31 @@
from python.helpers import persist_chat, tokens
from python.helpers.extension import Extension
from agent import LoopData
import asyncio
from python.helpers.log import LogItem
from python.helpers import log
import math
from python.extensions.before_main_llm_call._10_log_for_stream import build_heading, build_default_heading
class LogFromStream(Extension):
async def execute(
self,
loop_data: LoopData = LoopData(),
text: str = "",
parsed: dict = {},
**kwargs,
):
# get log item from loop data temporary params
log_item = loop_data.params_temporary["log_item_generating"]
if log_item is None:
return
# remove step parameter when done
if log_item.kvps is not None and "step" in log_item.kvps:
del log_item.kvps["step"]
# update the log item
log_item.update(kvps=log_item.kvps)

View file

@ -3,7 +3,8 @@ import io
import base64
from PIL import Image
from typing import Dict, List, Optional, Tuple
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
from werkzeug.datastructures import FileStorage
from python.helpers.print_style import PrintStyle
@ -41,10 +42,10 @@ class AttachmentManager:
except AttributeError:
return False
def save_file(self, file, filename: str) -> Tuple[str, Dict]:
def save_file(self, file: FileStorage, name: str) -> Tuple[str, Dict]:
"""Save file and return path and metadata"""
try:
filename = secure_filename(filename)
filename = safe_filename(name)
if not filename:
raise ValueError("Invalid filename")
@ -68,7 +69,7 @@ class AttachmentManager:
return file_path, metadata
except Exception as e:
PrintStyle.error(f"Error saving file {filename}: {e}")
PrintStyle.error(f"Error saving file {name}: {e}")
return None, {} # type: ignore
def generate_image_preview(self, image_path: str, max_size: int = 800) -> Optional[str]:

View file

@ -60,28 +60,12 @@ class BackupService:
# Ensure paths don't have double slashes
agent_root = self.agent_zero_root.rstrip('/')
return f"""# Agent Zero Knowledge (excluding defaults)
{agent_root}/knowledge/**
!{agent_root}/knowledge/default/**
# Agent Zero Skills (excluding builtins)
{agent_root}/skills/**
!{agent_root}/skills/builtin/**
# Memory (excluding embeddings cache)
{agent_root}/memory/**
!{agent_root}/memory/**/embeddings/**
# Configuration and Settings (CRITICAL)
{agent_root}/.env
{agent_root}/tmp/settings.json
{agent_root}/tmp/secrets.env
{agent_root}/tmp/chats/**
{agent_root}/tmp/scheduler/**
{agent_root}/tmp/uploads/**
# User data
return f"""# User data
# All persistent user data is now centralized in /usr for easier backup and restore
{agent_root}/usr/**
# Explicitly include .env
{agent_root}/usr/.env
"""
def _get_agent_zero_version(self) -> str:

View file

@ -73,7 +73,7 @@ class DockerContainerManager:
if existing_container:
if existing_container.status != 'running':
PrintStyle.standard(f"Starting existing container: {self.name} for safe code execution...")
if self.logger: self.logger.log(type="info", content=f"Starting existing container: {self.name} for safe code execution...", temp=True)
if self.logger: self.logger.log(type="info", content=f"Starting existing container: {self.name} for safe code execution...")
existing_container.start()
self.container = existing_container
@ -84,7 +84,7 @@ class DockerContainerManager:
# PrintStyle.standard(f"Container with name '{self.name}' is already running with ID: {existing_container.id}")
else:
PrintStyle.standard(f"Initializing docker container {self.name} for safe code execution...")
if self.logger: self.logger.log(type="info", content=f"Initializing docker container {self.name} for safe code execution...", temp=True)
if self.logger: self.logger.log(type="info", content=f"Initializing docker container {self.name} for safe code execution...")
self.container = self.client.containers.run(
self.image,

View file

@ -359,6 +359,7 @@ class DocumentQueryHelper:
self.agent = agent
self.store = DocumentQueryStore.get(agent)
self.progress_callback = progress_callback or (lambda x: None)
self.store_lock = asyncio.Lock()
async def document_qa(
self, document_uris: List[str], questions: Sequence[str]
@ -527,9 +528,10 @@ class DocumentQueryHelper:
if add_to_db:
self.progress_callback(f"Indexing document")
await self.agent.handle_intervention()
success, ids = await self.store.add_document(
document_content, document_uri_norm
)
async with self.store_lock:
success, ids = await self.store.add_document(
document_content, document_uri_norm
)
if not success:
self.progress_callback(f"Failed to index document")
raise ValueError(

View file

@ -15,7 +15,7 @@ def load_dotenv():
def get_dotenv_file_path():
return get_abs_path(".env")
return get_abs_path("usr/.env")
def get_dotenv_value(key: str, default: Any = None):
# load_dotenv()

View file

@ -537,7 +537,7 @@ async def read_messages(
port: int = 993,
username: str = "",
password: str = "",
download_folder: str = "tmp/email",
download_folder: str = "usr/email",
options: Optional[Dict[str, Any]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> List[Message]:

View file

@ -13,7 +13,7 @@ def error_text(e: Exception):
return str(e)
def format_error(e: Exception, start_entries=6, end_entries=4):
def format_error(e: Exception, start_entries=20, end_entries=15):
# format traceback from the provided exception instead of the most recent one
traceback_text = ''.join(traceback.format_exception(type(e), e, e.__traceback__))
# Split the traceback into lines

View file

@ -98,7 +98,6 @@ class AgentZeroWorker(Worker): # type: ignore[misc]
heading="Remote user message",
content=agent_message.message,
kvps={"from": "A2A"},
temp=False,
)
# Process message through Agent Zero (includes response)

View file

@ -4,7 +4,7 @@ import shutil
import base64
import subprocess
from typing import Dict, List, Tuple, Any
from werkzeug.utils import secure_filename
from python.helpers.security import safe_filename
from datetime import datetime
from python.helpers import files
@ -19,6 +19,7 @@ class FileBrowser:
}
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024 # 1MB
def __init__(self):
# if runtime.is_development():
@ -69,7 +70,9 @@ class FileBrowser:
for file in files:
try:
if file and self._is_allowed_file(file.filename, file):
filename = secure_filename(file.filename)
filename = safe_filename(file.filename)
if not filename:
raise ValueError("Invalid filename")
file_path = target_dir / filename
file.save(str(file_path))
@ -107,6 +110,78 @@ class FileBrowser:
PrintStyle.error(f"Error deleting {file_path}: {e}")
return False
def rename_item(self, file_path: str, new_name: str) -> bool:
try:
if not new_name or new_name in {".", ".."}:
raise ValueError("Invalid new name")
if "/" in new_name or "\\" in new_name:
raise ValueError("New name cannot include path separators")
full_path = (self.base_dir / file_path).resolve()
if not str(full_path).startswith(str(self.base_dir)):
raise ValueError("Invalid path")
if not full_path.exists():
raise FileNotFoundError("File or folder not found")
new_path = full_path.with_name(new_name)
if not str(new_path).startswith(str(self.base_dir)):
raise ValueError("Invalid target path")
if full_path == new_path:
return True
if new_path.exists():
raise FileExistsError("Target already exists")
os.rename(full_path, new_path)
return True
except Exception as e:
PrintStyle.error(f"Error renaming {file_path}: {e}")
raise
def create_folder(self, parent_path: str, folder_name: str) -> bool:
try:
if not folder_name or folder_name in {".", ".."}:
raise ValueError("Invalid folder name")
if "/" in folder_name or "\\" in folder_name:
raise ValueError("Folder name cannot include path separators")
parent_full = (self.base_dir / parent_path).resolve()
if not str(parent_full).startswith(str(self.base_dir)):
raise ValueError("Invalid parent path")
target_dir = (parent_full / folder_name).resolve()
if not str(target_dir).startswith(str(self.base_dir)):
raise ValueError("Invalid target path")
if target_dir.exists():
raise FileExistsError("Folder already exists")
os.makedirs(target_dir, exist_ok=False)
return True
except Exception as e:
PrintStyle.error(f"Error creating folder {folder_name}: {e}")
raise
def save_text_file(self, file_path: str, content: str) -> bool:
try:
if not isinstance(content, str):
raise ValueError("Content must be a string")
content_size = len(content.encode("utf-8"))
if content_size > self.MAX_TEXT_FILE_SIZE:
raise ValueError("File exceeds 1 MB and cannot be edited")
full_path = (self.base_dir / file_path).resolve()
if not str(full_path).startswith(str(self.base_dir)):
raise ValueError("Invalid path")
if full_path.exists() and full_path.is_dir():
raise ValueError("Target is a directory")
os.makedirs(full_path.parent, exist_ok=True)
with open(full_path, "w", encoding="utf-8") as file:
file.write(content)
return True
except Exception as e:
PrintStyle.error(f"Error saving file {file_path}: {e}")
raise
def _is_allowed_file(self, filename: str, file) -> bool:
# allow any file to be uploaded in file browser

View file

@ -230,6 +230,42 @@ def read_file_base64(relative_path):
return base64.b64encode(f.read()).decode("utf-8")
def is_probably_binary_bytes(data: bytes, threshold: float = 0.3) -> bool:
"""
Binary detection.
- Fast path: NUL bytes => binary
- Otherwise: treat high ratio of suspicious ASCII control bytes as binary.
(We intentionally do NOT treat bytes >= 0x80 as binary to avoid false
positives for UTF-8 text.)
"""
if not data:
return False
if b"\x00" in data:
return True
# Count suspicious control bytes
allowed = {8, 9, 10, 12, 13} # \b \t \n \f \r
suspicious = sum(
1
for b in data
if ((b < 32 and b not in allowed) or b == 127)
)
return (suspicious / len(data)) > threshold
def is_probably_binary_file(
file_path: str, sample_size: int = 10 * 1024, threshold: float = 0.3
) -> bool:
"""Binary detection by reading only the first ~sample_size bytes of a file."""
try:
with open(file_path, "rb") as f:
sample = f.read(sample_size)
except (FileNotFoundError, PermissionError, OSError):
raise OSError(f"Unable to read file for binary detection: {file_path}")
return is_probably_binary_bytes(sample, threshold=threshold)
def replace_placeholders_text(_content: str, **kwargs):
# Replace placeholders with values from kwargs
for key, value in kwargs.items():
@ -410,6 +446,10 @@ def move_dir(old_path: str, new_path: str):
abs_new = get_abs_path(new_path)
if not os.path.isdir(abs_old):
return # nothing to rename
# ensure parent directory exists
os.makedirs(os.path.dirname(abs_new), exist_ok=True)
try:
os.rename(abs_old, abs_new)
except Exception:
@ -505,12 +545,14 @@ def dirname(path: str):
def is_in_base_dir(path: str):
# check if the given path is within the base directory
base_dir = get_base_dir()
# normalize paths to handle relative paths and symlinks
return is_in_dir(path,get_base_dir())
def is_in_dir(path:str,dir:str):
# check if the given path is within the directory
abs_path = os.path.abspath(path)
# check if the absolute path starts with the base directory
return os.path.commonpath([abs_path, base_dir]) == base_dir
abs_dir = os.path.abspath(dir)
return os.path.commonpath([abs_path, abs_dir]) == abs_dir
def get_subdirectories(

View file

@ -1,5 +1,6 @@
from dataclasses import dataclass, field
import json
import time
from typing import Any, Literal, Optional, Dict, TypeVar, TYPE_CHECKING
T = TypeVar("T")
@ -20,12 +21,14 @@ Type = Literal[
"agent",
"browser",
"code_exe",
"subagent",
"error",
"hint",
"info",
"progress",
"response",
"tool",
"mcp",
"input",
"user",
"util",
@ -126,14 +129,16 @@ class LogItem:
type: Type
heading: str = ""
content: str = ""
temp: bool = False
update_progress: Optional[ProgressUpdate] = "persistent"
kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
id: Optional[str] = None # Add id field
guid: str = ""
timestamp: float = 0.0
agentno: int = 0
def __post_init__(self):
self.guid = self.log.guid
self.timestamp = self.timestamp or time.time()
def update(
self,
@ -141,7 +146,6 @@ class LogItem:
heading: str | None = None,
content: str | None = None,
kvps: dict | None = None,
temp: bool | None = None,
update_progress: ProgressUpdate | None = None,
**kwargs,
):
@ -152,7 +156,6 @@ class LogItem:
heading=heading,
content=content,
kvps=kvps,
temp=temp,
update_progress=update_progress,
**kwargs,
)
@ -179,8 +182,9 @@ class LogItem:
"type": self.type,
"heading": self.heading,
"content": self.content,
"temp": self.temp,
"kvps": self.kvps,
"timestamp": self.timestamp,
"agentno": self.agentno,
}
@ -199,18 +203,27 @@ class Log:
heading: str | None = None,
content: str | None = None,
kvps: dict | None = None,
temp: bool | None = None,
update_progress: ProgressUpdate | None = None,
id: Optional[str] = None,
**kwargs,
) -> LogItem:
# add a minimal item to the log
# Determine agent number from streaming agent
agentno = 0
if self.context and self.context.streaming_agent:
agentno = self.context.streaming_agent.number
item = LogItem(
log=self,
no=len(self.logs),
type=type,
agentno=agentno,
)
# Set duration on previous item and mark it as updated
if self.logs:
prev = self.logs[-1]
self.updates += [prev.no]
self.logs.append(item)
# and update it (to have just one implementation)
@ -220,7 +233,6 @@ class Log:
heading=heading,
content=content,
kvps=kvps,
temp=temp,
update_progress=update_progress,
id=id,
**kwargs,
@ -234,7 +246,6 @@ class Log:
heading: str | None = None,
content: str | None = None,
kvps: dict | None = None,
temp: bool | None = None,
update_progress: ProgressUpdate | None = None,
id: Optional[str] = None,
**kwargs,
@ -247,9 +258,6 @@ class Log:
if type is not None:
item.type = type
if temp is not None:
item.temp = temp
if update_progress is not None:
item.update_progress = update_progress

View file

@ -23,6 +23,7 @@ from datetime import timedelta
import json
from python.helpers import errors
from python.helpers import settings
from python.helpers.log import LogItem
import httpx
@ -90,7 +91,6 @@ def initialize_mcp(mcp_servers_config: str):
AgentContext.log_to_all(
type="warning",
content=f"Failed to update MCP settings: {e}",
temp=False,
)
PrintStyle(
@ -101,6 +101,14 @@ def initialize_mcp(mcp_servers_config: str):
class MCPTool(Tool):
"""MCP Tool wrapper"""
def get_log_object(self) -> LogItem:
return self.agent.context.log.log(
type="mcp",
heading=f"icon://extension {self.agent.agent_name}: Using MCP tool '{self.name}'",
content="",
kvps={"tool_name": self.name, **self.args},
)
async def execute(self, **kwargs: Any):
error = ""
try:
@ -1071,9 +1079,9 @@ class MCPClientRemote(MCPClientBase):
server: MCPServerRemote = cast(MCPServerRemote, self.server)
set = settings.get_settings()
# Use lower timeouts for faster failure detection
init_timeout = min(server.init_timeout or set["mcp_client_init_timeout"], 5)
tool_timeout = min(server.tool_timeout or set["mcp_client_tool_timeout"], 10)
# Resolve timeout: check server config first, then settings, defaulting to 5s/10s
init_timeout = server.init_timeout or set["mcp_client_init_timeout"] or 5
tool_timeout = server.tool_timeout or set["mcp_client_tool_timeout"] or 10
client_factory = CustomHTTPClientFactory(verify=server.verify)
# Check if this is a streaming HTTP type

View file

@ -139,7 +139,7 @@ class Memory:
log_item.stream(progress="\nInitializing VectorDB")
em_dir = files.get_abs_path(
"memory/embeddings"
"tmp/memory/embeddings"
) # just caching, no need to parameterize
db_dir = abs_db_dir(memory_subdir)
@ -335,6 +335,16 @@ class Memory:
filename_pattern="**/SKILL.md",
)
# load custom instruments descriptions
index = knowledge_import.load_knowledge(
log_item,
files.get_abs_path("usr/instruments"),
index,
{"area": Memory.Area.INSTRUMENTS.value},
filename_pattern="**/*.md",
recursive=True,
)
return index
def get_document_by_id(self, id: str) -> Document | None:
@ -485,7 +495,9 @@ class Memory:
def get_custom_knowledge_subdir_abs(agent: Agent) -> str:
for dir in agent.config.knowledge_subdirs:
if dir != "default":
return files.get_abs_path("knowledge", dir)
if dir == "custom":
return files.get_abs_path("usr/knowledge")
return files.get_abs_path("usr/knowledge", dir)
raise Exception("No custom knowledge subdir set")
@ -501,7 +513,7 @@ def abs_db_dir(memory_subdir: str) -> str:
return files.get_abs_path(get_project_meta_folder(memory_subdir[9:]), "memory")
# standard subdirs
return files.get_abs_path("memory", memory_subdir)
return files.get_abs_path("usr/memory", memory_subdir)
def abs_knowledge_dir(knowledge_subdir: str, *sub_dirs: str) -> str:
@ -513,7 +525,11 @@ def abs_knowledge_dir(knowledge_subdir: str, *sub_dirs: str) -> str:
get_project_meta_folder(knowledge_subdir[9:]), "knowledge", *sub_dirs
)
# standard subdirs
return files.get_abs_path("knowledge", knowledge_subdir, *sub_dirs)
if knowledge_subdir == "default":
return files.get_abs_path("knowledge", *sub_dirs)
if knowledge_subdir == "custom":
return files.get_abs_path("usr/knowledge", *sub_dirs)
return files.get_abs_path("usr/knowledge", knowledge_subdir, *sub_dirs)
def get_memory_subdir_abs(agent: Agent) -> str:
@ -548,7 +564,7 @@ def get_existing_memory_subdirs() -> list[str]:
)
# Get subdirectories from memory folder
subdirs = files.get_subdirectories("memory", exclude="embeddings")
subdirs = files.get_subdirectories("usr/memory")
project_subdirs = files.get_subdirectories(get_projects_parent_folder())
for project_subdir in project_subdirs:

View file

@ -130,7 +130,6 @@ class MemoryConsolidator:
if log_item:
log_item.update(
progress="No similar memories found, inserting new memory",
temp=True
)
try:
db = await Memory.get(self.agent)
@ -153,7 +152,6 @@ class MemoryConsolidator:
if log_item:
log_item.update(
progress=f"Found {len(similar_memories)} similar memories, analyzing...",
temp=True,
similar_memories_count=len(similar_memories)
)
@ -174,7 +172,6 @@ class MemoryConsolidator:
if log_item:
log_item.update(
progress=f"Filtered out {deleted_count} deleted memories, {len(valid_similar_memories)} remain for analysis",
temp=True,
race_condition_detected=True,
deleted_similar_memories_count=deleted_count
)
@ -185,7 +182,6 @@ class MemoryConsolidator:
if log_item:
log_item.update(
progress="No valid similar memories remain, inserting new memory",
temp=True
)
try:
db = await Memory.get(self.agent)
@ -220,7 +216,6 @@ class MemoryConsolidator:
if log_item:
log_item.update(
progress="LLM analysis suggests skipping consolidation",
temp=True
)
try:
db = await Memory.get(self.agent)

View file

@ -0,0 +1,182 @@
import os
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from agent import AgentContext
from python.helpers.print_style import PrintStyle
QUEUE_KEY = "message_queue"
QUEUE_SEQ_KEY = "message_queue_seq"
UPLOAD_FOLDER = "/a0/tmp/uploads"
def get_queue(context: "AgentContext") -> list:
"""Get current queue from context.data."""
return context.get_data(QUEUE_KEY) or []
def _get_next_seq(context: "AgentContext") -> int:
"""Get next sequence number."""
seq = context.get_data(QUEUE_SEQ_KEY) or 0
seq += 1
context.set_data(QUEUE_SEQ_KEY, seq)
return seq
def _sync_output(context: "AgentContext"):
"""Sync queue to output_data for frontend polling."""
queue = get_queue(context)
# Truncate text for frontend display
truncated = []
for item in queue:
truncated.append({
"id": item["id"],
"seq": item.get("seq", 0),
"text": item["text"][:100] + "..." if len(item["text"]) > 100 else item["text"],
"attachments": [a.split("/")[-1] for a in item.get("attachments", [])],
"attachment_count": len(item.get("attachments", [])),
})
context.set_output_data(QUEUE_KEY, truncated)
def add(context: "AgentContext", text: str, attachments: list[str] | None = None) -> dict:
"""Add message to queue. Attachments should be filenames, will be converted to full paths."""
queue = get_queue(context)
# Convert filenames to full paths
full_paths = []
for att in (attachments or []):
if att.startswith("/"):
full_paths.append(att)
else:
full_paths.append(f"{UPLOAD_FOLDER}/{att}")
item = {
"id": str(uuid.uuid4())[:8],
"seq": _get_next_seq(context),
"text": text,
"attachments": full_paths,
}
queue.append(item)
context.set_data(QUEUE_KEY, queue)
_sync_output(context)
return item
def remove(context: "AgentContext", item_id: str | None = None) -> int:
"""Remove item(s). If item_id is None, clears all. Returns remaining count."""
if not item_id:
context.set_data(QUEUE_KEY, [])
context.set_output_data(QUEUE_KEY, [])
return 0
queue = [i for i in get_queue(context) if i["id"] != item_id]
context.set_data(QUEUE_KEY, queue)
_sync_output(context)
return len(queue)
def pop_first(context: "AgentContext") -> dict | None:
"""Remove and return first item."""
queue = get_queue(context)
if not queue:
return None
item = queue.pop(0)
context.set_data(QUEUE_KEY, queue)
_sync_output(context)
return item
def pop_item(context: "AgentContext", item_id: str) -> dict | None:
"""Remove and return specific item."""
queue = get_queue(context)
for i, item in enumerate(queue):
if item["id"] == item_id:
queue.pop(i)
context.set_data(QUEUE_KEY, queue)
_sync_output(context)
return item
return None
def has_queue(context: "AgentContext") -> bool:
"""Check if queue has items."""
return len(get_queue(context)) > 0
def log_user_message(
context: "AgentContext",
message: str,
attachment_paths: list[str],
message_id: str | None = None,
source: str = "",
):
"""Log user message to console and UI. Used by message API and queue processing."""
# Prepare attachment filenames for logging
attachment_filenames = (
[os.path.basename(path) for path in attachment_paths]
if attachment_paths
else []
)
# Print to console
label = f"User message{source}:"
PrintStyle(
background_color="#6C3483", font_color="white", bold=True, padding=True
).print(label)
PrintStyle(font_color="white", padding=False).print(f"> {message}")
if attachment_filenames:
PrintStyle(font_color="white", padding=False).print("Attachments:")
for filename in attachment_filenames:
PrintStyle(font_color="white", padding=False).print(f"- {filename}")
# Log to UI
context.log.log(
type="user",
heading="",
content=message,
kvps={"attachments": attachment_filenames},
id=message_id,
)
def send_message(context: "AgentContext", item: dict, source: str = " (from queue)"):
"""Send a single queued message (log + communicate)."""
from agent import UserMessage # Import here to avoid circular import
message = item.get("text", "")
attachments = item.get("attachments", [])
log_user_message(context, message, attachments, source=source)
context.communicate(UserMessage(message, attachments))
def send_next(context: "AgentContext") -> bool:
"""Send next queued message. Returns True if sent, False if queue empty."""
if not has_queue(context):
return False
item = pop_first(context)
if item:
send_message(context, item)
return True
return False
def send_all_aggregated(context: "AgentContext") -> int:
"""Aggregate and send all queued messages as one. Returns count of items sent."""
from agent import UserMessage # Import here to avoid circular import
if not has_queue(context):
return 0
items = []
while has_queue(context):
items.append(pop_first(context))
# Combine texts with separator
text = "\n\n---\n\n".join(i["text"] for i in items if i["text"])
attachments = [a for i in items for a in i.get("attachments", [])]
log_user_message(context, text, attachments, source=" (queued batch)")
context.communicate(UserMessage(text, attachments))
return len(items)

112
python/helpers/migration.py Normal file
View file

@ -0,0 +1,112 @@
import os
from python.helpers import files
from python.helpers.print_style import PrintStyle
def migrate_user_data() -> None:
"""
Migrate user data from /tmp and other locations to /usr.
"""
PrintStyle().print("Checking for data migration...")
# --- Migrate Directories -------------------------------------------------------
# Move directories from tmp/ or other source locations to usr/
_move_dir("tmp/chats", "usr/chats")
_move_dir("tmp/scheduler", "usr/scheduler", overwrite=True)
_move_dir("tmp/uploads", "usr/uploads")
_move_dir("tmp/upload", "usr/upload")
_move_dir("tmp/downloads", "usr/downloads")
_move_dir("tmp/email", "usr/email")
_move_dir("knowledge/custom", "usr/knowledge", overwrite=True)
_move_dir("instruments/custom", "usr/instruments", overwrite=True)
# --- Migrate Files -------------------------------------------------------------
# Move specific configuration files to usr/
_move_file("tmp/settings.json", "usr/settings.json")
_move_file("tmp/secrets.env", "usr/secrets.env")
_move_file(".env", "usr/.env", overwrite=True)
# --- Special Migration Cases ---------------------------------------------------
# Migrate Memory
_migrate_memory()
# Flatten default directories (knowledge/default -> knowledge/, etc.)
# We use _merge_dir_contents because we want to move the *contents* of default/
# into the parent directory, not move the default directory itself.
_merge_dir_contents("knowledge/default", "knowledge")
_merge_dir_contents("instruments/default", "instruments")
# --- Cleanup -------------------------------------------------------------------
# Remove obsolete directories after migration
_cleanup_obsolete()
PrintStyle().print("Migration check complete.")
# --- Helper Functions ----------------------------------------------------------
def _move_dir(src: str, dst: str, overwrite: bool = False) -> None:
"""
Move a directory from src to dst if src exists and dst does not.
"""
if files.exists(src) and (not files.exists(dst) or overwrite):
PrintStyle().print(f"Migrating {src} to {dst}...")
if overwrite and files.exists(dst):
files.delete_dir(dst)
files.move_dir(src, dst)
def _move_file(src: str, dst: str, overwrite: bool = False) -> None:
"""
Move a file from src to dst if src exists and dst does not.
"""
if files.exists(src) and (not files.exists(dst) or overwrite):
PrintStyle().print(f"Migrating {src} to {dst}...")
files.move_file(src, dst)
def _migrate_memory(base_path: str = "memory") -> None:
"""
Migrate memory subdirectories.
"""
subdirs = files.get_subdirectories(base_path)
for subdir in subdirs:
if subdir == "embeddings":
# Special case: Embeddings
_move_dir("memory/embeddings", "tmp/memory/embeddings")
else:
# Move other memory items to usr/memory
dst = f"usr/memory/{subdir}"
_move_dir(f"memory/{subdir}", dst)
def _merge_dir_contents(src_parent: str, dst_parent: str) -> None:
"""
Moves all subdirectories from src_parent to dst_parent.
Useful for flattening structures like 'knowledge/default/*' -> 'knowledge/*'.
"""
if not files.exists(src_parent):
return
# Iterate over subdirectories in the source parent
subdirs = files.get_subdirectories(src_parent)
for subdir in subdirs:
src = f"{src_parent}/{subdir}"
dst = f"{dst_parent}/{subdir}"
# Move the subdirectory if it doesn't exist in destination
_move_dir(src, dst)
def _cleanup_obsolete() -> None:
"""
Remove directories that are no longer needed.
"""
to_remove = [
"knowledge/default",
"instruments/default",
"memory"
]
for path in to_remove:
if files.exists(path):
PrintStyle().print(f"Removing {path}...")
files.delete_dir(path)

View file

@ -9,7 +9,7 @@ from initialize import initialize_agent
from python.helpers.log import Log, LogItem
CHATS_FOLDER = "tmp/chats"
CHATS_FOLDER = "usr/chats"
LOG_SIZE = 1000
CHAT_FILE_NAME = "chat.json"
@ -262,17 +262,17 @@ def _deserialize_log(data: dict[str, Any]) -> "Log":
# Deserialize the list of LogItem objects
i = 0
for item_data in data.get("logs", []):
log.logs.append(
LogItem(
log=log, # restore the log reference
no=i, # item_data["no"],
type=item_data["type"],
heading=item_data.get("heading", ""),
content=item_data.get("content", ""),
kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
temp=item_data.get("temp", False),
)
)
log.logs.append(LogItem(
log=log, # restore the log reference
no=i, # item_data["no"],
type=item_data["type"],
heading=item_data.get("heading", ""),
content=item_data.get("content", ""),
kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
timestamp=item_data.get("timestamp", 0.0),
agentno=item_data.get("agentno", 0),
id=item_data.get("id"),
))
log.updates.append(i)
i += 1

View file

@ -1,7 +1,8 @@
import yaml
from python.helpers import files
from typing import List, Dict, Optional, TypedDict
from typing import List, Dict, Optional, TypedDict, Literal
ModelType = Literal["chat", "embedding"]
# Type alias for UI option items
class FieldOption(TypedDict):
@ -68,16 +69,15 @@ class ProviderManager:
opts.append({"value": pid, "label": name})
self._options[p_type] = opts
def get_providers(self, provider_type: str) -> List[FieldOption]:
def get_providers(self, provider_type: ModelType) -> List[FieldOption]:
"""Returns a list of providers for a given type (e.g., 'chat', 'embedding')."""
return self._options.get(provider_type, []) if self._options else []
def get_raw_providers(self, provider_type: str) -> List[Dict[str, str]]:
def get_raw_providers(self, provider_type: ModelType) -> List[Dict[str, str]]:
"""Return raw provider dictionaries for advanced use-cases."""
return self._raw.get(provider_type, []) if self._raw else []
def get_provider_config(self, provider_type: str, provider_id: str) -> Optional[Dict[str, str]]:
def get_provider_config(self, provider_type: ModelType, provider_id: str) -> Optional[Dict[str, str]]:
"""Return the metadata dict for a single provider id (case-insensitive)."""
provider_id_low = provider_id.lower()
for p in self.get_raw_providers(provider_type):
@ -86,16 +86,16 @@ class ProviderManager:
return None
def get_providers(provider_type: str) -> List[FieldOption]:
def get_providers(provider_type: ModelType) -> List[FieldOption]:
"""Convenience function to get providers of a specific type."""
return ProviderManager.get_instance().get_providers(provider_type)
def get_raw_providers(provider_type: str) -> List[Dict[str, str]]:
def get_raw_providers(provider_type: ModelType) -> List[Dict[str, str]]:
"""Return full metadata for providers of a given type."""
return ProviderManager.get_instance().get_raw_providers(provider_type)
def get_provider_config(provider_type: str, provider_id: str) -> Optional[Dict[str, str]]:
def get_provider_config(provider_type: ModelType, provider_id: str) -> Optional[Dict[str, str]]:
"""Return metadata for a single provider (None if not found)."""
return ProviderManager.get_instance().get_provider_config(provider_type, provider_id)
return ProviderManager.get_instance().get_provider_config(provider_type, provider_id)

View file

@ -15,7 +15,7 @@ if TYPE_CHECKING:
# New alias-based placeholder format §§secret(KEY)
ALIAS_PATTERN = r"§§secret\(([A-Za-z_][A-Za-z0-9_]*)\)"
DEFAULT_SECRETS_FILE = "tmp/secrets.env"
DEFAULT_SECRETS_FILE = "usr/secrets.env"
def alias_for_key(key: str, placeholder: str = "§§secret({key})") -> str:

View file

@ -0,0 +1,49 @@
import re
import unicodedata
from pathlib import Path
from typing import Final, Optional
# Forbidden characters:
# Linux/Unix: / and NULL byte
# Windows: < > : " / \ | ? * and ASCII control characters (0-31)
# Shell-sensitive: ~ to prevent accidental home directory access
FORBIDDEN_CHARS_RE: Final = re.compile(r'[<>:"|?*~/\\\x00-\x1f\x7f]')
# Windows reserved filenames
WINDOWS_RESERVED: Final = frozenset({
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
})
FILENAME_MAX_LENGTH: Final = 255
def safe_filename(filename: str) -> Optional[str]:
# Normalize Unicode (NFC)
filename = unicodedata.normalize("NFC", str(filename))
# Replace forbidden chars
filename = FORBIDDEN_CHARS_RE.sub("_", filename)
# Remove leading/trailing spaces and trailing dots
filename = filename.lstrip(" ").rstrip(". ")
path = Path(filename)
suffixes = ''.join(path.suffixes)
stem = path.name[:-len(suffixes)] if suffixes else path.name
# Check Windows reserved names
if stem.upper() in WINDOWS_RESERVED:
filename = f"{stem}-{suffixes}"
# Truncate if too long
if len(filename) > FILENAME_MAX_LENGTH:
max_stem_len = FILENAME_MAX_LENGTH - len(suffixes)
if max_stem_len > 0:
# Truncate filename
stem = stem[:max_stem_len]
filename = stem + suffixes
else:
# Extension is too long, truncate everything
filename = filename[:FILENAME_MAX_LENGTH]
if not filename:
return None
return filename

File diff suppressed because it is too large Load diff

View file

@ -82,7 +82,6 @@ class SSHInteractiveSession:
self.logger.log(
type="info",
content=f"SSH Connection attempt {errors}...",
temp=True,
)
time.sleep(5)
else:

View file

@ -22,11 +22,11 @@ from python.helpers.print_style import PrintStyle
from python.helpers.defer import DeferredTask
from python.helpers.files import get_abs_path, make_dirs, read_file, write_file
from python.helpers.localization import Localization
from python.helpers import projects
from python.helpers import projects, guids
import pytz
from typing import Annotated
SCHEDULER_FOLDER = "tmp/scheduler"
SCHEDULER_FOLDER = "usr/scheduler"
# ----------------------
# Task Models
@ -118,7 +118,7 @@ class TaskPlan(BaseModel):
class BaseTask(BaseModel):
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
uuid: str = Field(default_factory=lambda: guids.generate_id())
context_id: Optional[str] = Field(default=None)
state: TaskState = Field(default=TaskState.IDLE)
name: str = Field()
@ -874,7 +874,7 @@ class TaskScheduler:
# Log the message with message_id and attachments
context.log.log(
type="user",
heading="User message",
heading="",
content=task_prompt,
kvps={"attachments": attachment_filenames},
id=str(uuid.uuid4()),

View file

@ -1,6 +1,13 @@
from flaredantic import FlareTunnel, FlareConfig, ServeoConfig, ServeoTunnel
from flaredantic import (
FlareTunnel, FlareConfig,
ServeoConfig, ServeoTunnel,
MicrosoftTunnel, MicrosoftConfig,
notifier, NotifyData, NotifyEvent
)
import threading
from collections import deque
from python.helpers.print_style import PrintStyle
# Singleton to manage the tunnel instance
class TunnelManager:
@ -19,6 +26,35 @@ class TunnelManager:
self.tunnel_url = None
self.is_running = False
self.provider = None
self.notifications = deque(maxlen=50)
self._subscribed = False
def _on_notify(self, data: NotifyData):
"""Handle notifications from flaredantic"""
self.notifications.append({
"event": data.event.value,
"message": data.message,
"data": data.data
})
def _ensure_subscribed(self):
"""Subscribe to flaredantic notifications if not already"""
if not self._subscribed:
notifier.subscribe(self._on_notify)
self._subscribed = True
def get_notifications(self):
"""Get and clear pending notifications"""
notifications = list(self.notifications)
self.notifications.clear()
return notifications
def get_last_error(self):
"""Check for recent error in notifications without clearing"""
for n in reversed(list(self.notifications)):
if n['event'] == NotifyEvent.ERROR.value:
return n['message']
return None
def start_tunnel(self, port=80, provider="serveo"):
"""Start a new tunnel or return the existing one's URL"""
@ -26,6 +62,8 @@ class TunnelManager:
return self.tunnel_url
self.provider = provider
self._ensure_subscribed()
self.notifications.clear()
try:
# Start tunnel in a separate thread to avoid blocking
@ -34,6 +72,9 @@ class TunnelManager:
if self.provider == "cloudflared":
config = FlareConfig(port=port, verbose=True)
self.tunnel = FlareTunnel(config)
elif self.provider == "microsoft":
config = MicrosoftConfig(port=port, verbose=True) # type: ignore
self.tunnel = MicrosoftTunnel(config)
else: # Default to serveo
config = ServeoConfig(port=port) # type: ignore
self.tunnel = ServeoTunnel(config)
@ -42,23 +83,34 @@ class TunnelManager:
self.tunnel_url = self.tunnel.tunnel_url
self.is_running = True
except Exception as e:
print(f"Error in tunnel thread: {str(e)}")
error_msg = str(e)
PrintStyle.error(f"Error in tunnel thread: {error_msg}")
self.notifications.append({
"event": NotifyEvent.ERROR.value,
"message": error_msg,
"data": None
})
tunnel_thread = threading.Thread(target=run_tunnel)
tunnel_thread.daemon = True
tunnel_thread.start()
# Wait for tunnel to start (max 15 seconds instead of 5)
for _ in range(150): # Increased from 50 to 150 iterations
# Wait for tunnel to start (no timeout - user may need time for login)
import time
while True:
if self.tunnel_url:
break
import time
# Check if we have errors
if any(n['event'] == NotifyEvent.ERROR.value for n in self.notifications):
break
# Check if thread died without producing URL
if not tunnel_thread.is_alive():
break
time.sleep(0.1)
return self.tunnel_url
except Exception as e:
print(f"Error starting tunnel: {str(e)}")
PrintStyle.error(f"Error starting tunnel: {str(e)}")
return None
def stop_tunnel(self):

View file

@ -1,5 +1,4 @@
from typing import Any, List, Sequence
import uuid
from langchain_community.vectorstores import FAISS
# faiss needs to be patched for python 3.12 on arm #TODO remove once not needed
@ -17,6 +16,7 @@ from langchain.embeddings import CacheBackedEmbeddings
from simpleeval import simple_eval
from agent import Agent
from python.helpers import guids
class MyFaiss(FAISS):
@ -99,7 +99,7 @@ class VectorDB:
return result
async def insert_documents(self, docs: list[Document]):
ids = [str(uuid.uuid4()) for _ in range(len(docs))]
ids = [guids.generate_id() for _ in range(len(docs))]
if ids:
for doc, id in zip(docs, ids):
@ -141,7 +141,7 @@ def cosine_normalizer(val: float) -> float:
def get_comparator(condition: str):
def comparator(data: dict[str, Any]):
try:
result = simple_eval(condition, {}, data)
result = simple_eval(condition, names=data)
return result
except Exception as e:
# PrintStyle.error(f"Error evaluating condition: {e}")

View file

@ -56,7 +56,7 @@ class State:
disable_security=True,
chromium_sandbox=False,
accept_downloads=True,
downloads_path=files.get_abs_path("tmp/downloads"),
downloads_path=files.get_abs_path("usr/downloads"),
allowed_domains=["*", "http://*", "https://*"],
executable_path=pw_binary,
keep_alive=True,

View file

@ -45,7 +45,7 @@ class Delegation(Tool):
def get_log_object(self):
return self.agent.context.log.log(
type="tool",
type="subagent",
heading=f"icon://communication {self.agent.agent_name}: Calling Subordinate Agent",
content="",
kvps=self.args,