From 97fb0a47edb7de6f8f076d12280ac5c56f632323 Mon Sep 17 00:00:00 2001 From: yunxiafei Date: Mon, 16 Mar 2026 21:07:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=9APyOpenClaw=20=E6=A0=B8=E5=BF=83=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目结构搭建 - Agent 核心类实现 - LLM 客户端(OpenAI) - 工具系统框架 - 配置管理 - 简单示例 版本: v0.1.0 --- .env.example | 18 +++ .gitignore | 64 +++++++++ README.md | 119 ++++++++++++++++ examples/simple_agent.py | 103 ++++++++++++++ pyopenclaw/__init__.py | 19 +++ pyopenclaw/config/__init__.py | 0 pyopenclaw/config/settings.py | 65 +++++++++ pyopenclaw/core/__init__.py | 0 pyopenclaw/core/agent.py | 226 +++++++++++++++++++++++++++++++ pyopenclaw/core/llm_client.py | 222 ++++++++++++++++++++++++++++++ pyopenclaw/core/memory.py | 94 +++++++++++++ pyopenclaw/core/tool_registry.py | 102 ++++++++++++++ pyopenclaw/gateway/__init__.py | 0 pyopenclaw/tools/__init__.py | 0 pyopenclaw/tools/base.py | 66 +++++++++ pyopenclaw/tools/file_tools.py | 184 +++++++++++++++++++++++++ pyproject.toml | 62 +++++++++ requirements.txt | 21 +++ tests/__init__.py | 0 19 files changed, 1365 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/simple_agent.py create mode 100644 pyopenclaw/__init__.py create mode 100644 pyopenclaw/config/__init__.py create mode 100644 pyopenclaw/config/settings.py create mode 100644 pyopenclaw/core/__init__.py create mode 100644 pyopenclaw/core/agent.py create mode 100644 pyopenclaw/core/llm_client.py create mode 100644 pyopenclaw/core/memory.py create mode 100644 pyopenclaw/core/tool_registry.py create mode 100644 pyopenclaw/gateway/__init__.py create mode 100644 pyopenclaw/tools/__init__.py create mode 100644 pyopenclaw/tools/base.py create mode 100644 pyopenclaw/tools/file_tools.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/__init__.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3970c67 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a852da7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b78168e --- /dev/null +++ b/README.md @@ -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 +**作者**: 云下飞 +**助手**: 小白 🐶 diff --git a/examples/simple_agent.py b/examples/simple_agent.py new file mode 100644 index 0000000..4c47540 --- /dev/null +++ b/examples/simple_agent.py @@ -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()) diff --git a/pyopenclaw/__init__.py b/pyopenclaw/__init__.py new file mode 100644 index 0000000..65a6e1b --- /dev/null +++ b/pyopenclaw/__init__.py @@ -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", +] diff --git a/pyopenclaw/config/__init__.py b/pyopenclaw/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyopenclaw/config/settings.py b/pyopenclaw/config/settings.py new file mode 100644 index 0000000..c975d09 --- /dev/null +++ b/pyopenclaw/config/settings.py @@ -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() diff --git a/pyopenclaw/core/__init__.py b/pyopenclaw/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyopenclaw/core/agent.py b/pyopenclaw/core/agent.py new file mode 100644 index 0000000..22c1104 --- /dev/null +++ b/pyopenclaw/core/agent.py @@ -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}") diff --git a/pyopenclaw/core/llm_client.py b/pyopenclaw/core/llm_client.py new file mode 100644 index 0000000..fd25ca7 --- /dev/null +++ b/pyopenclaw/core/llm_client.py @@ -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}") diff --git a/pyopenclaw/core/memory.py b/pyopenclaw/core/memory.py new file mode 100644 index 0000000..95b8d7f --- /dev/null +++ b/pyopenclaw/core/memory.py @@ -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 diff --git a/pyopenclaw/core/tool_registry.py b/pyopenclaw/core/tool_registry.py new file mode 100644 index 0000000..124d610 --- /dev/null +++ b/pyopenclaw/core/tool_registry.py @@ -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 diff --git a/pyopenclaw/gateway/__init__.py b/pyopenclaw/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyopenclaw/tools/__init__.py b/pyopenclaw/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyopenclaw/tools/base.py b/pyopenclaw/tools/base.py new file mode 100644 index 0000000..93e85c4 --- /dev/null +++ b/pyopenclaw/tools/base.py @@ -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, + ) diff --git a/pyopenclaw/tools/file_tools.py b/pyopenclaw/tools/file_tools.py new file mode 100644 index 0000000..ef24601 --- /dev/null +++ b/pyopenclaw/tools/file_tools.py @@ -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}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b8987a1 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bed4b53 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29