chore: initial commit for Pretor v0.1.0-alpha

正式发布 Pretor 平台的首个 alpha 版本。本项目旨在构建一个基于分布式架构的多智能体协同工作流水线。

核心功能实现:
1. 建立基于 BaseIndividual 的动态插件加载机制。
2. 实现三类核心 worker_individual 子个体。
3. 集成 Ray 框架支持分布式集群调度。
4. 基于 PostgreSQL 的全量持久化存储方案。
5. 提供完整的 FastAPI 后端与 React 前端交互界面。
This commit is contained in:
2026-04-29 10:09:07 +08:00
commit d84212f780
163 changed files with 19251 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+104
View File
@@ -0,0 +1,104 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import jwt
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import HTTPException, status, Request
from pydantic import BaseModel, ValidationError
from pretor.core.database.table.user import User
from pwdlib import PasswordHash
class TokenData(BaseModel):
user_id: str
username: Optional[str] = None
exp: Optional[int] = None
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24
password_hasher = PasswordHash.recommended()
class Accessor:
@staticmethod
def _decode_token(token: str) -> TokenData:
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM]
)
return TokenData(**payload)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期",
)
except (jwt.InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭证",
)
@staticmethod
def _create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": int(expire.timestamp())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hasher.verify(plain_password, hashed_password)
@staticmethod
def get_current_user(request: Request) -> TokenData:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未提供认证头部",
)
token = auth_header.split(" ")[1]
return Accessor._decode_token(token)
@staticmethod
def login_hashed_password(user: User, password: str) -> str:
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
)
if not Accessor.verify_password(password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
token_payload = {
"user_id": str(user.user_id),
"username": user.user_name
}
return Accessor._create_access_token(data=token_payload)
@staticmethod
def hash_password(password: str) -> str:
if not password:
raise ValueError("密码不能为空")
if len(password) < 6:
raise ValueError("密码长度不能小于 6 位")
return password_hasher.hash(password)
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from pydantic import BaseModel
class ResponseModel(BaseModel):
pass
class DepsModel(BaseModel):
pass
class InputModel(BaseModel):
pass
+25
View File
@@ -0,0 +1,25 @@
from rich.console import Console
from rich.text import Text
import yaml
def print_banner() -> None:
with open("config/config.yml","r") as config:
config = yaml.load(config, Loader=yaml.FullLoader)
version = config.get("version", "unknown")
pretor_banner = """
██████╗ ██████╗ ███████╗████████╗ ██████╗ ██████╗
██╔══██╗██╔══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
██████╔╝██████╔╝█████╗ ██║ ██║ ██║██████╔╝
██╔═══╝ ██╔══██╗██╔══╝ ██║ ██║ ██║██╔══██╗
██║ ██║ ██║███████╗ ██║ ╚██████╔╝██║ ██║
╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
"""
console = Console()
banner_colored = Text(pretor_banner, style="gold3 bold")
console.print(banner_colored)
console.print("=" * 40, style="dim") # dim=灰色,低调
console.print("🚀 Multi-Agent Orchestration Platform", style="blue")
console.print(f"📦 Version: {version}", style="green")
console.print("👤 Author: zhaoxi826", style="yellow")
console.print("📜 License: Apache 2.0", style="magenta")
console.print("🐙 github: https://github.com/zhaoxi826/pretor", style="yellow")
console.print("=" * 40, style="dim")
+53
View File
@@ -0,0 +1,53 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Annotated
from fastapi import Depends, HTTPException
from pretor.utils.access import Accessor, TokenData
from pretor.core.database.table.user import UserAuthority
from pretor.utils.ray_hook import ray_actor_hook
async def get_authority(user_id: str) -> UserAuthority:
from pretor.utils.error import UserNotExistError
postgres_database = ray_actor_hook("postgres_database").postgres_database
try:
user_authority = await postgres_database.get_user_authority.remote(user_id=user_id)
return user_authority
except UserNotExistError:
raise HTTPException(
status_code=401,
detail="用户不存在或已被删除,请重新登录"
)
except Exception as e:
# Check if it's a RayTaskError wrapping UserNotExistError
if "UserNotExistError" in str(e):
raise HTTPException(
status_code=401,
detail="用户不存在或已被删除,请重新登录"
)
raise
class RoleChecker:
def __init__(self, **kwargs):
self.allowed_roles = kwargs.get("allowed_roles", )
async def __call__(self,
token_data: Annotated[TokenData, Depends(Accessor.get_current_user)]):
user_authority = await get_authority(token_data.user_id)
if user_authority < self.allowed_roles:
raise HTTPException(
status_code=403,
detail={"message": f"User {token_data.user_id} does not have allowed roles"},
)
return token_data
+50
View File
@@ -0,0 +1,50 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
class RetryableError(Exception):
"""基类:所有可重试错误(如网络断开、抖动等临时性故障)"""
pass
class NonRetryableError(Exception):
"""基类:所有不可重试错误(如数据验证失败、类型错误等业务逻辑故障)"""
pass
class DemandError(NonRetryableError):
pass
class ModelNotExistError(Exception):
pass
class UserError(Exception):
pass
class UserNotExistError(UserError):
pass
class UserPasswordError(UserError):
pass
class ProviderError(Exception):
pass
class ProviderNotExistError(ProviderError):
pass
class WorkflowError(Exception):
pass
class WorkflowExit(WorkflowError):
pass
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import importlib.util
import os
import sys
from typing import Callable, Dict, List
import pathlib
from pretor.utils.ray_hook import ray_actor_hook
from pretor.utils.logger import get_logger
logger = get_logger('get_tool')
_tool_cache: Dict[str, Callable] = {}
def _get_tool_func(tool_name: str) -> Callable | None:
func = _tool_cache.get(tool_name, None)
if func:
return func
app_root = "/app"
tool_plugin_dir = os.path.join(app_root, "pretor", "plugin", "tool_plugin", tool_name)
if not os.path.exists(tool_plugin_dir) or not os.path.isdir(tool_plugin_dir):
logger.error(f"Tool directory not found: {tool_plugin_dir}")
return None
init_file = os.path.join(tool_plugin_dir, "__init__.py")
if not os.path.exists(init_file):
logger.error(f"Tool init file not found: {init_file}")
return None
try:
module_name = f"pretor.plugin.tool_plugin.{tool_name}"
spec = importlib.util.spec_from_file_location(module_name, init_file)
if spec is None or spec.loader is None:
logger.error(f"Failed to create spec for {module_name}")
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
func = getattr(module, tool_name, None)
if not callable(func):
logger.error(f"Tool function '{tool_name}' not found or not callable in {module_name}")
return None
_tool_cache[tool_name] = func
return func
except Exception as e:
logger.error(f"Failed to load module {module_name}: {e}")
return None
def del_tool_cache(tool_name: str) -> None:
if tool_name in _tool_cache:
del _tool_cache[tool_name]
def load_tools_from_list(tool_names: List[str] | None) -> List[Callable]:
if not tool_names:
return []
tool_list = []
for tool_name in tool_names:
tool_func = _get_tool_func(tool_name)
if tool_func:
tool_list.append(tool_func)
return tool_list
+44
View File
@@ -0,0 +1,44 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from loguru import logger
from rich.logging import RichHandler
from loguru._logger import Logger
def setup_logger() -> Logger:
logger.remove()
def format_record(record):
# Format string for rich handler
actor = record["extra"].get("actor_name", "System")
trace_id = record["extra"].get("trace_id", "")
trace_str = f" | trace_id:({trace_id})" if trace_id else ""
return f"actor:({actor}){trace_str} : {record['message']}"
logger.configure(extra={"actor_name": "System", "trace_id": ""})
logger.add(
RichHandler(rich_tracebacks=True, markup=True, show_time=False, show_level=False, show_path=False),
format=format_record,
level="DEBUG",
enqueue=True, # 异步记录
)
return logger
global_logger = setup_logger()
def get_logger(actor_name: str, trace_id: str = "") -> Logger:
return global_logger.bind(actor_name=actor_name, trace_id=trace_id)
+37
View File
@@ -0,0 +1,37 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Type, TypeVar
from pydantic import BaseModel
T = TypeVar("T", bound=Type[BaseModel])
def pickle(cls: T) -> T:
"""
类装饰器pickle
通过装饰继承了BaseModel的类,用pydantic的高效序列化替代python原生__reduce__魔术方法,实现ray在通讯时的高效序列化
Args:
cls: 继承了BaseModel类的类,需要被装饰的对象
Returns:
返回被重写了__reduce__魔术方法的cls类
"""
def __reduce__(self):
# 1. 序列化:触发 Pydantic-core (Rust) 的极速序列化
data = self.model_dump_json()
# 2. 反序列化:告诉 Pickle 重建时调用 cls.model_validate_json
return cls.model_validate_json, (data,)
cls.__reduce__ = __reduce__
return cls
+50
View File
@@ -0,0 +1,50 @@
# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ray
from functools import lru_cache
class ActorList:
def __init__(self):
super().__setattr__('dict', {})
def __setattr__(self, key, value):
self.dict[key] = value
def __getattr__(self, key):
if key in self.dict:
return self.dict[key]
raise AttributeError(f"ActorList 对象没有属性 '{key}'")
def __delattr__(self, key):
if key in self.dict:
del self.dict[key]
else:
raise AttributeError(f"ActorList对象没有属性 '{key}'")
@lru_cache(maxsize=128)
def _get_cached_actor_handle(actor_name: str):
"""缓存接口"""
return ray.get_actor(actor_name, namespace="pretor")
def clear_actor_cache():
"""清理接口"""
_get_cached_actor_handle.cache_clear()
def ray_actor_hook(*actor_names: str):
actor_list = ActorList()
for actor_name in actor_names:
handle = _get_cached_actor_handle(actor_name)
setattr(actor_list, actor_name, handle)
return actor_list
+31
View File
@@ -0,0 +1,31 @@
import asyncio
from functools import wraps
from pretor.utils.error import RetryableError
def retry_on_retryable_error(max_retries=3, base_delay=1):
def decorator(func):
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except RetryableError:
if attempt == max_retries - 1:
raise
await asyncio.sleep(base_delay * (2 ** attempt))
return async_wrapper
else:
@wraps(func)
def sync_wrapper(*args, **kwargs):
import time
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except RetryableError:
if attempt == max_retries - 1:
raise
time.sleep(base_delay * (2 ** attempt))
return sync_wrapper
return decorator