初始化项目:PyOpenClaw 核心框架
- 项目结构搭建 - Agent 核心类实现 - LLM 客户端(OpenAI) - 工具系统框架 - 配置管理 - 简单示例 版本: v0.1.0
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# OpenAI API Key
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
||||
# OpenAI Base URL(可选,用于自定义端点)
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# Anthropic API Key(可选)
|
||||
# ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
|
||||
# Gateway 配置
|
||||
GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=8000
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Debug 模式
|
||||
DEBUG=false
|
||||
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# MyPy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
sessions/
|
||||
workspace/
|
||||
*.session.json
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# PyOpenClaw
|
||||
|
||||
**Python 实现 OpenClaw 核心功能**
|
||||
|
||||
## 项目简介
|
||||
|
||||
本项目是用 Python 重新实现 OpenClaw 核心功能的学习项目。
|
||||
|
||||
OpenClaw 是一个多渠道 AI Gateway,支持多种消息渠道(Discord、Telegram、Slack、飞书等)和多种 LLM 提供商(OpenAI、Anthropic、Google 等)。
|
||||
|
||||
## 核心目标
|
||||
|
||||
1. **深入理解 OpenClaw 架构**
|
||||
2. **掌握 Agent 运行原理**
|
||||
3. **实践 Python 异步编程**
|
||||
4. **构建自己的智能体团队**
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Python 3.11+
|
||||
- FastAPI - HTTP 框架
|
||||
- asyncio - 异步运行时
|
||||
- Pydantic - 数据验证
|
||||
- OpenAI SDK - LLM 调用
|
||||
- Docker SDK - 沙箱执行
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
pyopenclaw/
|
||||
├── pyopenclaw/
|
||||
│ ├── __init__.py
|
||||
│ ├── core/ # 核心层
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # Agent 核心类
|
||||
│ │ ├── llm_client.py # LLM 客户端
|
||||
│ │ ├── tool_registry.py # 工具注册表
|
||||
│ │ ├── session.py # 会话管理
|
||||
│ │ └── memory.py # 记忆系统
|
||||
│ │
|
||||
│ ├── tools/ # 工具实现
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # 工具基类
|
||||
│ │ ├── file_tools.py # 文件工具
|
||||
│ │ ├── exec_tools.py # 执行工具
|
||||
│ │ └── web_tools.py # 网络工具
|
||||
│ │
|
||||
│ ├── gateway/ # Gateway 服务
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── server.py # HTTP 服务
|
||||
│ │ ├── auth.py # 认证
|
||||
│ │ └── middleware.py # 中间件
|
||||
│ │
|
||||
│ └── config/ # 配置管理
|
||||
│ ├── __init__.py
|
||||
│ └── settings.py # 配置类
|
||||
│
|
||||
├── tests/ # 测试
|
||||
├── docs/ # 文档
|
||||
├── pyproject.toml # 项目配置
|
||||
├── requirements.txt # 依赖
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 开发进度
|
||||
|
||||
### 阶段 1:核心 Agent(第 1-3 周)
|
||||
|
||||
- [x] 项目初始化
|
||||
- [ ] Agent 核心类
|
||||
- [ ] LLM 客户端
|
||||
- [ ] 基础工具(read/write/exec)
|
||||
- [ ] 简单会话管理
|
||||
|
||||
### 阶段 2:Gateway 服务(第 4-5 周)
|
||||
|
||||
- [ ] FastAPI 服务
|
||||
- [ ] WebSocket 支持
|
||||
- [ ] 认证系统
|
||||
- [ ] 会话持久化
|
||||
|
||||
### 阶段 3:工具系统扩展(第 6-7 周)
|
||||
|
||||
- [ ] 工具注册表
|
||||
- [ ] 工具策略
|
||||
- [ ] 更多工具实现
|
||||
|
||||
### 阶段 4:渠道插件(第 8 周起)
|
||||
|
||||
- [ ] 渠道基类
|
||||
- [ ] Discord 插件
|
||||
- [ ] Telegram 插件
|
||||
- [ ] 其他渠道
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 运行示例
|
||||
python examples/simple_agent.py
|
||||
```
|
||||
|
||||
## 学习资源
|
||||
|
||||
- [OpenClaw 官网](https://openclaw.ai)
|
||||
- [OpenClaw GitHub](https://github.com/openclaw/openclaw)
|
||||
- [TypeScript 基础教程](./docs/typescript-tutorial.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
**创建时间**: 2026-03-16
|
||||
**作者**: 云下飞
|
||||
**助手**: 小白 🐶
|
||||
103
examples/simple_agent.py
Normal file
103
examples/simple_agent.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
简单 Agent 示例。
|
||||
|
||||
演示如何使用 PyOpenClaw 创建一个基本的 Agent。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from pyopenclaw.config.settings import AgentConfig
|
||||
from pyopenclaw.core.agent import Agent
|
||||
from pyopenclaw.core.llm_client import create_llm_client
|
||||
from pyopenclaw.core.tool_registry import ToolRegistry
|
||||
from pyopenclaw.tools.file_tools import ReadTool, WriteTool
|
||||
|
||||
|
||||
async def main():
|
||||
"""运行简单 Agent 示例"""
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
# 检查 API Key
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
print("错误: 请设置 OPENAI_API_KEY 环境变量")
|
||||
print("示例: export OPENAI_API_KEY='your-api-key'")
|
||||
return
|
||||
|
||||
# 1. 创建 Agent 配置
|
||||
config = AgentConfig(
|
||||
name="小白",
|
||||
model="openai/gpt-4o",
|
||||
system_prompt="你是一个有用的 AI 助手,名叫小白。你是一只忠诚的电子宠物狗。",
|
||||
tools=["read", "write"],
|
||||
max_iterations=10,
|
||||
)
|
||||
|
||||
# 2. 创建 LLM 客户端
|
||||
llm_client = create_llm_client(
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# 3. 创建工具注册表并注册工具
|
||||
tool_registry = ToolRegistry()
|
||||
tool_registry.register(ReadTool())
|
||||
tool_registry.register(WriteTool())
|
||||
|
||||
# 4. 创建 Agent
|
||||
agent = Agent(
|
||||
config=config,
|
||||
llm_client=llm_client,
|
||||
tool_registry=tool_registry,
|
||||
)
|
||||
|
||||
print("=" * 50)
|
||||
print("PyOpenClaw 简单示例")
|
||||
print("=" * 50)
|
||||
print(f"Agent 名称: {config.name}")
|
||||
print(f"模型: {config.model}")
|
||||
print(f"可用工具: {config.tools}")
|
||||
print("=" * 50)
|
||||
|
||||
# 5. 运行 Agent
|
||||
user_input = "你好,请介绍一下你自己"
|
||||
print(f"\n用户: {user_input}\n")
|
||||
print("Agent 回复:")
|
||||
print("-" * 50)
|
||||
|
||||
async for event in agent.run(user_input):
|
||||
event_type = event.get("type")
|
||||
|
||||
if event_type == "content":
|
||||
# 内容输出
|
||||
print(event["content"])
|
||||
if event.get("done"):
|
||||
break
|
||||
|
||||
elif event_type == "tool_call":
|
||||
# 工具调用
|
||||
print(f"\n[调用工具: {event['name']}]")
|
||||
print(f"参数: {event['arguments']}")
|
||||
|
||||
elif event_type == "tool_result":
|
||||
# 工具结果
|
||||
result = event["result"]
|
||||
if len(result) > 100:
|
||||
result = result[:100] + "..."
|
||||
print(f"结果: {result}\n")
|
||||
|
||||
elif event_type == "error":
|
||||
# 错误
|
||||
print(f"\n错误: {event['content']}")
|
||||
break
|
||||
|
||||
print("-" * 50)
|
||||
print("\n运行完成!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
19
pyopenclaw/__init__.py
Normal file
19
pyopenclaw/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
PyOpenClaw - Python implementation of OpenClaw core functionality.
|
||||
|
||||
这是一个学习项目,用于深入理解 OpenClaw 和 Agent 的原理。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "云下飞"
|
||||
|
||||
from pyopenclaw.core.agent import Agent, AgentConfig
|
||||
from pyopenclaw.core.llm_client import LLMClient
|
||||
from pyopenclaw.core.tool_registry import ToolRegistry
|
||||
|
||||
__all__ = [
|
||||
"Agent",
|
||||
"AgentConfig",
|
||||
"LLMClient",
|
||||
"ToolRegistry",
|
||||
]
|
||||
0
pyopenclaw/config/__init__.py
Normal file
0
pyopenclaw/config/__init__.py
Normal file
65
pyopenclaw/config/settings.py
Normal file
65
pyopenclaw/config/settings.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
配置管理模块。
|
||||
|
||||
对应 OpenClaw 的 src/agents/defaults.ts 和 src/config/config.ts
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
"""模型配置"""
|
||||
provider: str = Field(default="openai", description="LLM 提供商")
|
||||
model_id: str = Field(default="gpt-4o", description="模型 ID")
|
||||
context_window: int = Field(default=128000, description="上下文窗口大小")
|
||||
max_tokens: int = Field(default=4096, description="最大输出 token")
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Agent 配置
|
||||
|
||||
对应 OpenClaw 的 AgentConfig 接口
|
||||
"""
|
||||
name: str = Field(default="Agent", description="Agent 名称")
|
||||
model: str = Field(default="openai/gpt-4o", description="模型(provider/model_id 格式)")
|
||||
system_prompt: str = Field(default="", description="系统提示词")
|
||||
max_iterations: int = Field(default=100, description="最大迭代次数")
|
||||
timeout: int = Field(default=300, description="超时时间(秒)")
|
||||
tools: List[str] = Field(default_factory=list, description="可用工具列表")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""全局配置
|
||||
|
||||
对应 OpenClaw 的 openclaw.json 配置文件
|
||||
"""
|
||||
# 基础配置
|
||||
app_name: str = "PyOpenClaw"
|
||||
debug: bool = False
|
||||
|
||||
# LLM 配置
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
anthropic_api_key: Optional[str] = None
|
||||
|
||||
# Agent 默认配置
|
||||
default_model: str = "openai/gpt-4o"
|
||||
default_timeout: int = 300
|
||||
default_max_iterations: int = 100
|
||||
|
||||
# Gateway 配置
|
||||
gateway_host: str = "0.0.0.0"
|
||||
gateway_port: int = 8000
|
||||
|
||||
# 日志配置
|
||||
log_level: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
0
pyopenclaw/core/__init__.py
Normal file
0
pyopenclaw/core/__init__.py
Normal file
226
pyopenclaw/core/agent.py
Normal file
226
pyopenclaw/core/agent.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Agent 核心模块。
|
||||
|
||||
对应 OpenClaw 的 src/agents/pi-embedded-runner/run.ts
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pyopenclaw.config.settings import AgentConfig, settings
|
||||
from pyopenclaw.core.llm_client import (
|
||||
BaseLLMClient,
|
||||
LLMClient,
|
||||
Message,
|
||||
LLMResponse,
|
||||
ToolCall,
|
||||
)
|
||||
from pyopenclaw.core.tool_registry import ToolRegistry
|
||||
from pyopenclaw.core.memory import Memory
|
||||
|
||||
|
||||
class AgentStatus(Enum):
|
||||
"""Agent 状态"""
|
||||
IDLE = "idle"
|
||||
RUNNING = "running"
|
||||
WAITING_TOOL = "waiting_tool"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMeta:
|
||||
"""Agent 元数据"""
|
||||
session_id: str
|
||||
provider: str
|
||||
model: str
|
||||
usage: Optional[Dict[str, int]] = None
|
||||
prompt_tokens: Optional[int] = None
|
||||
compaction_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
"""Agent 运行结果"""
|
||||
content: str
|
||||
is_error: bool = False
|
||||
meta: Optional[AgentMeta] = None
|
||||
|
||||
|
||||
class Agent:
|
||||
"""Agent 核心类
|
||||
|
||||
这是 OpenClaw 的 PI Agent 运行时的 Python 实现。
|
||||
|
||||
核心功能:
|
||||
1. 接收用户输入
|
||||
2. 调用 LLM 进行思考
|
||||
3. 决定是否调用工具
|
||||
4. 执行工具并获取结果
|
||||
5. 循环直到任务完成
|
||||
|
||||
对应 TypeScript 代码:
|
||||
- src/agents/pi-embedded-runner/run.ts: runEmbeddedPiAgent
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AgentConfig,
|
||||
llm_client: BaseLLMClient,
|
||||
tool_registry: ToolRegistry,
|
||||
memory: Optional[Memory] = None,
|
||||
):
|
||||
self.config = config
|
||||
self.llm = llm_client
|
||||
self.tools = tool_registry
|
||||
self.memory = memory or Memory()
|
||||
self.status = AgentStatus.IDLE
|
||||
self._iteration = 0
|
||||
self._session_id = f"session-{id(self)}"
|
||||
|
||||
logger.info(
|
||||
f"Agent 初始化: name={config.name}, model={config.model}, "
|
||||
f"tools={config.tools}"
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
user_input: str,
|
||||
stream: bool = False,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Agent 主运行循环。
|
||||
|
||||
对应 OpenClaw 的 runEmbeddedPiAgent 函数。
|
||||
|
||||
Args:
|
||||
user_input: 用户输入
|
||||
stream: 是否流式输出
|
||||
|
||||
Yields:
|
||||
运行过程中的事件
|
||||
"""
|
||||
self.status = AgentStatus.RUNNING
|
||||
self.memory.add_message("user", user_input)
|
||||
|
||||
logger.info(f"Agent 开始运行: input={user_input[:50]}...")
|
||||
|
||||
try:
|
||||
while self._iteration < self.config.max_iterations:
|
||||
self._iteration += 1
|
||||
logger.debug(f"迭代 {self._iteration}/{self.config.max_iterations}")
|
||||
|
||||
# 1. 构建 LLM 请求
|
||||
messages = self._build_messages()
|
||||
tool_schemas = self._get_tool_schemas()
|
||||
|
||||
# 2. 调用 LLM
|
||||
logger.debug("调用 LLM...")
|
||||
response = await self.llm.chat(
|
||||
messages=messages,
|
||||
tools=tool_schemas,
|
||||
system_prompt=self.config.system_prompt,
|
||||
)
|
||||
|
||||
# 3. 处理响应
|
||||
if response.stop_reason == "error":
|
||||
yield {
|
||||
"type": "error",
|
||||
"content": "LLM 调用失败",
|
||||
}
|
||||
break
|
||||
|
||||
# 4. 检查工具调用
|
||||
if response.tool_calls:
|
||||
self.status = AgentStatus.WAITING_TOOL
|
||||
|
||||
# 执行所有工具调用
|
||||
for tool_call in response.tool_calls:
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"name": tool_call.name,
|
||||
"arguments": tool_call.arguments,
|
||||
}
|
||||
|
||||
# 执行工具
|
||||
result = await self._execute_tool(tool_call)
|
||||
|
||||
# 添加工具结果到记忆
|
||||
self.memory.add_tool_result(tool_call.name, result)
|
||||
|
||||
yield {
|
||||
"type": "tool_result",
|
||||
"name": tool_call.name,
|
||||
"result": result,
|
||||
}
|
||||
else:
|
||||
# 5. 生成最终回复
|
||||
self.memory.add_message("assistant", response.content)
|
||||
|
||||
yield {
|
||||
"type": "content",
|
||||
"content": response.content,
|
||||
"done": True,
|
||||
}
|
||||
|
||||
logger.info(f"Agent 运行完成: iterations={self._iteration}")
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Agent 运行超时")
|
||||
yield {
|
||||
"type": "error",
|
||||
"content": "运行超时,请重试或使用 /new 开始新会话",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Agent 运行失败: {e}")
|
||||
yield {
|
||||
"type": "error",
|
||||
"content": f"运行失败: {e}",
|
||||
}
|
||||
finally:
|
||||
self.status = AgentStatus.ENDED
|
||||
|
||||
async def _execute_tool(self, tool_call: ToolCall) -> str:
|
||||
"""
|
||||
执行工具。
|
||||
|
||||
对应 OpenClaw 的工具执行逻辑。
|
||||
"""
|
||||
logger.info(f"执行工具: {tool_call.name}, 参数: {tool_call.arguments}")
|
||||
|
||||
# 1. 检查工具是否存在
|
||||
if not self.tools.has(tool_call.name):
|
||||
return f"Error: 工具不存在: {tool_call.name}"
|
||||
|
||||
# 2. 检查工具策略
|
||||
if not self.tools.is_allowed(tool_call.name, self.config.name):
|
||||
return f"Error: 工具不允许: {tool_call.name}"
|
||||
|
||||
# 3. 执行工具
|
||||
try:
|
||||
result = await self.tools.execute(
|
||||
tool_call.name,
|
||||
tool_call.arguments,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def _build_messages(self) -> List[Message]:
|
||||
"""构建消息列表"""
|
||||
return self.memory.get_messages()
|
||||
|
||||
def _get_tool_schemas(self) -> List[Dict]:
|
||||
"""获取工具模式定义"""
|
||||
return self.tools.get_schemas(self.config.tools)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重置 Agent 状态"""
|
||||
self.memory.clear()
|
||||
self._iteration = 0
|
||||
self.status = AgentStatus.IDLE
|
||||
logger.info(f"Agent 已重置: {self.config.name}")
|
||||
222
pyopenclaw/core/llm_client.py
Normal file
222
pyopenclaw/core/llm_client.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
LLM 客户端模块。
|
||||
|
||||
对应 OpenClaw 的 src/agents/pi-embedded-runner/run.ts 中的 LLM 调用部分
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""消息"""
|
||||
role: str # user, assistant, system, tool
|
||||
content: str
|
||||
name: Optional[str] = None # 工具名称(role=tool 时)
|
||||
tool_calls: Optional[List[Dict]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""工具调用"""
|
||||
id: str
|
||||
name: str
|
||||
arguments: Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""LLM 响应"""
|
||||
content: Optional[str] = None
|
||||
tool_calls: List[ToolCall] = None
|
||||
stop_reason: str = "end_turn" # end_turn, tool_calls, error
|
||||
usage: Optional[Dict[str, int]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tool_calls is None:
|
||||
self.tool_calls = []
|
||||
|
||||
|
||||
class BaseLLMClient(ABC):
|
||||
"""LLM 客户端基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Message],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> LLMResponse:
|
||||
"""调用 LLM"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: List[Message],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""流式调用 LLM"""
|
||||
pass
|
||||
|
||||
|
||||
class OpenAIClient(BaseLLMClient):
|
||||
"""OpenAI 客户端
|
||||
|
||||
对应 OpenClaw 中的 OpenAI 提供商支持
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = "gpt-4o",
|
||||
base_url: Optional[str] = None,
|
||||
):
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
self.model = model
|
||||
logger.info(f"OpenAI 客户端初始化: model={model}")
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Message],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> LLMResponse:
|
||||
"""调用 OpenAI API"""
|
||||
# 构建消息列表
|
||||
formatted_messages = []
|
||||
|
||||
if system_prompt:
|
||||
formatted_messages.append({
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
})
|
||||
|
||||
for msg in messages:
|
||||
formatted_messages.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
**({"name": msg.name} if msg.name else {}),
|
||||
})
|
||||
|
||||
# 构建请求参数
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": formatted_messages,
|
||||
}
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(**kwargs)
|
||||
return self._parse_response(response)
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI API 调用失败: {e}")
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
stop_reason="error",
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: List[Message],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""流式调用 OpenAI API"""
|
||||
# 构建消息列表
|
||||
formatted_messages = []
|
||||
|
||||
if system_prompt:
|
||||
formatted_messages.append({
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
})
|
||||
|
||||
for msg in messages:
|
||||
formatted_messages.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
})
|
||||
|
||||
# 构建请求参数
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": formatted_messages,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
stream = await self.client.chat.completions.create(**kwargs)
|
||||
async for chunk in stream:
|
||||
if chunk.choices[0].delta.content:
|
||||
yield {
|
||||
"type": "content",
|
||||
"content": chunk.choices[0].delta.content,
|
||||
}
|
||||
if chunk.choices[0].delta.tool_calls:
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"tool_calls": chunk.choices[0].delta.tool_calls,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI 流式调用失败: {e}")
|
||||
yield {
|
||||
"type": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _parse_response(self, response) -> LLMResponse:
|
||||
"""解析响应"""
|
||||
message = response.choices[0].message
|
||||
|
||||
# 解析工具调用
|
||||
tool_calls = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
tool_calls.append(ToolCall(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=json.loads(tc.function.arguments),
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
content=message.content,
|
||||
tool_calls=tool_calls,
|
||||
stop_reason="tool_calls" if tool_calls else "end_turn",
|
||||
usage={
|
||||
"input": response.usage.prompt_tokens,
|
||||
"output": response.usage.completion_tokens,
|
||||
"total": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
)
|
||||
|
||||
|
||||
def create_llm_client(
|
||||
provider: str,
|
||||
model: str,
|
||||
api_key: str,
|
||||
base_url: Optional[str] = None,
|
||||
) -> BaseLLMClient:
|
||||
"""创建 LLM 客户端工厂函数"""
|
||||
if provider == "openai":
|
||||
return OpenAIClient(api_key=api_key, model=model, base_url=base_url)
|
||||
else:
|
||||
raise ValueError(f"不支持的提供商: {provider}")
|
||||
94
pyopenclaw/core/memory.py
Normal file
94
pyopenclaw/core/memory.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
记忆系统模块。
|
||||
|
||||
对应 OpenClaw 的会话消息管理
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pyopenclaw.core.llm_client import Message
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionMessage:
|
||||
"""会话消息"""
|
||||
role: str
|
||||
content: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
name: Optional[str] = None # 工具名称
|
||||
|
||||
|
||||
class Memory:
|
||||
"""记忆系统
|
||||
|
||||
管理对话历史和上下文。
|
||||
|
||||
对应 OpenClaw 的会话消息管理。
|
||||
"""
|
||||
|
||||
def __init__(self, max_messages: int = 100):
|
||||
self._messages: List[SessionMessage] = []
|
||||
self.max_messages = max_messages
|
||||
logger.debug(f"记忆系统初始化: max_messages={max_messages}")
|
||||
|
||||
def add_message(self, role: str, content: str) -> None:
|
||||
"""添加消息"""
|
||||
self._messages.append(SessionMessage(
|
||||
role=role,
|
||||
content=content,
|
||||
))
|
||||
logger.debug(f"添加消息: role={role}, content_len={len(content)}")
|
||||
|
||||
# 检查是否需要压缩
|
||||
if len(self._messages) > self.max_messages:
|
||||
self._compact()
|
||||
|
||||
def add_tool_result(self, tool_name: str, result: str) -> None:
|
||||
"""添加工具结果"""
|
||||
self._messages.append(SessionMessage(
|
||||
role="tool",
|
||||
content=result,
|
||||
name=tool_name,
|
||||
))
|
||||
logger.debug(f"添加工具结果: tool={tool_name}, result_len={len(result)}")
|
||||
|
||||
def get_messages(self) -> List[Message]:
|
||||
"""获取消息列表(转换为 LLM 格式)"""
|
||||
return [
|
||||
Message(
|
||||
role=msg.role,
|
||||
content=msg.content,
|
||||
name=msg.name,
|
||||
)
|
||||
for msg in self._messages
|
||||
]
|
||||
|
||||
def get_last_n_messages(self, n: int) -> List[Message]:
|
||||
"""获取最近 n 条消息"""
|
||||
return self.get_messages()[-n:]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空记忆"""
|
||||
self._messages.clear()
|
||||
logger.debug("记忆已清空")
|
||||
|
||||
def _compact(self) -> None:
|
||||
"""压缩记忆(保留最近的消息)"""
|
||||
# 保留最近 80% 的消息
|
||||
keep_count = int(self.max_messages * 0.8)
|
||||
self._messages = self._messages[-keep_count:]
|
||||
logger.info(f"记忆压缩: 保留 {keep_count} 条消息")
|
||||
|
||||
def get_message_count(self) -> int:
|
||||
"""获取消息数量"""
|
||||
return len(self._messages)
|
||||
|
||||
def get_context_size_estimate(self) -> int:
|
||||
"""估算上下文大小(简单估算)"""
|
||||
# 假设平均每字符 0.5 token
|
||||
total_chars = sum(len(msg.content) for msg in self._messages)
|
||||
return total_chars // 2
|
||||
102
pyopenclaw/core/tool_registry.py
Normal file
102
pyopenclaw/core/tool_registry.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
工具注册表模块。
|
||||
|
||||
对应 OpenClaw 的 src/agents/tool-policy.ts 和工具管理逻辑
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Type
|
||||
from loguru import logger
|
||||
|
||||
from pyopenclaw.tools.base import BaseTool, ToolSchema
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""工具注册表
|
||||
|
||||
管理所有可用的工具,提供注册、查询、执行功能。
|
||||
|
||||
对应 OpenClaw 的工具系统核心
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, BaseTool] = {}
|
||||
self._allowed_tools: Optional[set] = None # None 表示允许所有
|
||||
logger.debug("工具注册表初始化")
|
||||
|
||||
def register(self, tool: BaseTool) -> None:
|
||||
"""注册工具"""
|
||||
self._tools[tool.name] = tool
|
||||
logger.info(f"注册工具: {tool.name}")
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""注销工具"""
|
||||
if name in self._tools:
|
||||
del self._tools[name]
|
||||
logger.info(f"注销工具: {name}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Optional[BaseTool]:
|
||||
"""获取工具"""
|
||||
return self._tools.get(name)
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
return name in self._tools
|
||||
|
||||
def is_allowed(self, name: str, agent_name: str = None) -> bool:
|
||||
"""检查工具是否被允许"""
|
||||
if self._allowed_tools is None:
|
||||
return True
|
||||
return name in self._allowed_tools
|
||||
|
||||
def set_allowed_tools(self, tools: Optional[List[str]]) -> None:
|
||||
"""设置允许的工具列表"""
|
||||
self._allowed_tools = set(tools) if tools else None
|
||||
logger.debug(f"设置允许工具: {tools}")
|
||||
|
||||
def list_tools(self) -> List[str]:
|
||||
"""列出所有工具"""
|
||||
return list(self._tools.keys())
|
||||
|
||||
def get_schemas(self, tool_names: Optional[List[str]] = None) -> List[Dict]:
|
||||
"""获取工具模式列表
|
||||
|
||||
返回 OpenAI 格式的工具定义
|
||||
"""
|
||||
if tool_names is None:
|
||||
tools = list(self._tools.values())
|
||||
else:
|
||||
tools = [self._tools[name] for name in tool_names if name in self._tools]
|
||||
|
||||
return [tool.get_schema().to_openai_format() for tool in tools]
|
||||
|
||||
async def execute(self, name: str, arguments: Dict) -> str:
|
||||
"""执行工具"""
|
||||
tool = self._tools.get(name)
|
||||
if not tool:
|
||||
error_msg = f"工具不存在: {name}"
|
||||
logger.error(error_msg)
|
||||
return f"Error: {error_msg}"
|
||||
|
||||
try:
|
||||
logger.debug(f"执行工具: {name}, 参数: {arguments}")
|
||||
result = await tool.execute(**arguments)
|
||||
logger.debug(f"工具执行成功: {name}, 结果长度: {len(result)}")
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"工具执行失败: {name}, 错误: {e}"
|
||||
logger.error(error_msg)
|
||||
return f"Error: {error_msg}"
|
||||
|
||||
|
||||
# 全局工具注册表
|
||||
_global_registry: Optional[ToolRegistry] = None
|
||||
|
||||
|
||||
def get_global_registry() -> ToolRegistry:
|
||||
"""获取全局工具注册表"""
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = ToolRegistry()
|
||||
return _global_registry
|
||||
0
pyopenclaw/gateway/__init__.py
Normal file
0
pyopenclaw/gateway/__init__.py
Normal file
0
pyopenclaw/tools/__init__.py
Normal file
0
pyopenclaw/tools/__init__.py
Normal file
66
pyopenclaw/tools/base.py
Normal file
66
pyopenclaw/tools/base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
工具基类模块。
|
||||
|
||||
对应 OpenClaw 的 src/agents/pi-tools.ts
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Callable, Optional
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSchema:
|
||||
"""工具模式定义"""
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any]
|
||||
|
||||
def to_openai_format(self) -> Dict:
|
||||
"""转换为 OpenAI 工具格式"""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
"""工具基类
|
||||
|
||||
对应 OpenClaw 的工具定义
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""工具名称"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""工具描述"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> Dict[str, Any]:
|
||||
"""工具参数模式"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs) -> str:
|
||||
"""执行工具"""
|
||||
pass
|
||||
|
||||
def get_schema(self) -> ToolSchema:
|
||||
"""获取工具模式"""
|
||||
return ToolSchema(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
parameters=self.parameters,
|
||||
)
|
||||
184
pyopenclaw/tools/file_tools.py
Normal file
184
pyopenclaw/tools/file_tools.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
文件工具模块。
|
||||
|
||||
对应 OpenClaw 的 read/write/edit 工具
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
import aiofiles
|
||||
|
||||
from pyopenclaw.tools.base import BaseTool
|
||||
|
||||
|
||||
class ReadTool(BaseTool):
|
||||
"""读取文件工具"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "read"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "读取文件内容,支持指定行号范围"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "文件路径(绝对路径或相对路径)",
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "起始行号(从 0 开始,默认 0)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "读取行数(可选,默认读取全部)",
|
||||
},
|
||||
},
|
||||
"required": ["file_path"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
file_path: str,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> str:
|
||||
"""执行文件读取"""
|
||||
try:
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(file_path):
|
||||
return f"Error: 文件不存在: {file_path}"
|
||||
|
||||
# 异步读取文件
|
||||
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = await f.readlines()
|
||||
|
||||
# 应用偏移和限制
|
||||
if limit:
|
||||
lines = lines[offset:offset + limit]
|
||||
else:
|
||||
lines = lines[offset:]
|
||||
|
||||
# 返回内容
|
||||
content = "".join(lines)
|
||||
return content if content else "(文件为空)"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
class WriteTool(BaseTool):
|
||||
"""写入文件工具"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "write"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "写入文件内容,会覆盖已存在的文件"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "文件路径",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "文件内容",
|
||||
},
|
||||
},
|
||||
"required": ["file_path", "content"],
|
||||
}
|
||||
|
||||
async def execute(self, file_path: str, content: str) -> str:
|
||||
"""执行文件写入"""
|
||||
try:
|
||||
# 创建目录
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
# 异步写入文件
|
||||
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
||||
await f.write(content)
|
||||
|
||||
return f"成功写入文件: {file_path}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
class EditTool(BaseTool):
|
||||
"""编辑文件工具"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "edit"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "编辑文件,替换指定内容(精确匹配)"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "文件路径",
|
||||
},
|
||||
"old_content": {
|
||||
"type": "string",
|
||||
"description": "要替换的内容(必须精确匹配)",
|
||||
},
|
||||
"new_content": {
|
||||
"type": "string",
|
||||
"description": "替换后的内容",
|
||||
},
|
||||
},
|
||||
"required": ["file_path", "old_content", "new_content"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
file_path: str,
|
||||
old_content: str,
|
||||
new_content: str,
|
||||
) -> str:
|
||||
"""执行文件编辑"""
|
||||
try:
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(file_path):
|
||||
return f"Error: 文件不存在: {file_path}"
|
||||
|
||||
# 读取文件
|
||||
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
|
||||
# 检查内容是否存在
|
||||
if old_content not in content:
|
||||
return f"Error: 未找到要替换的内容"
|
||||
|
||||
# 替换内容
|
||||
new_file_content = content.replace(old_content, new_content)
|
||||
|
||||
# 写入文件
|
||||
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
||||
await f.write(new_file_content)
|
||||
|
||||
return f"成功编辑文件: {file_path}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
62
pyproject.toml
Normal file
62
pyproject.toml
Normal file
@@ -0,0 +1,62 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pyopenclaw"
|
||||
version = "0.1.0"
|
||||
description = "Python implementation of OpenClaw core functionality"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "云下飞"}
|
||||
]
|
||||
keywords = ["ai", "agent", "llm", "openclaw"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"openai>=1.10.0",
|
||||
"httpx>=0.26.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"loguru>=0.7.2",
|
||||
"aiofiles>=23.2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"black>=24.1.0",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pyopenclaw = "pyopenclaw.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py311"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# Core
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# LLM
|
||||
openai>=1.10.0
|
||||
httpx>=0.26.0
|
||||
|
||||
# Utils
|
||||
python-dotenv>=1.0.0
|
||||
loguru>=0.7.2
|
||||
aiofiles>=23.2.1
|
||||
|
||||
# Development
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.23.0
|
||||
black>=24.1.0
|
||||
ruff>=0.1.0
|
||||
mypy>=1.8.0
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user