chore: mini-openclaw 初始版本

Next.js 前端 + FastAPI/LangChain 后端透明 agent平台
- 含 .gitignore, 敏感文件(sessions数据/.env/venv等)已排除
- 待后续添加 trajectory 自进化机制
This commit is contained in:
NathanAlex7
2026-09-01 16:40:10 +08:00
committed by NathanAlex7
parent 73796a6abd
commit 42ce992a0f
76 changed files with 14207 additions and 0 deletions
View File
+183
View File
@@ -0,0 +1,183 @@
"""AgentManager — Core Agent using LangChain create_agent API with DeepSeek."""
import os
from pathlib import Path
from typing import Any, AsyncGenerator
from langchain_core.messages import HumanMessage, AIMessage
from config import get_rag_mode
from graph.prompt_builder import build_system_prompt
from graph.session_manager import session_manager
from tools import get_all_tools
class AgentManager:
"""Manages the Agent lifecycle: initialization, streaming, invocation."""
def __init__(self) -> None:
self._base_dir: Path | None = None
self._tools: list = []
self._llm = None
def initialize(self, base_dir: Path) -> None:
"""Initialize LLM (DeepSeek) and tools. Called once at startup."""
self._base_dir = base_dir
self._tools = get_all_tools(base_dir)
# Use langchain-deepseek for native DeepSeek support
from langchain_deepseek import ChatDeepSeek
self._llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
api_base=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.7,
streaming=True,
)
session_manager.initialize(base_dir)
print(f"🤖 Agent initialized with {len(self._tools)} tools (DeepSeek: {os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')})")
def _build_agent(self):
"""Build a fresh agent with current system prompt (re-reads files each time)."""
from langchain.agents import create_agent
assert self._base_dir is not None
assert self._llm is not None
rag_mode = get_rag_mode()
system_prompt = build_system_prompt(self._base_dir, rag_mode=rag_mode)
agent = create_agent(
model=self._llm,
tools=self._tools,
system_prompt=system_prompt,
)
return agent
def _build_messages(self, user_message: str, history: list[dict[str, Any]]) -> list:
"""Convert session history + new message into LangChain messages."""
messages = []
for msg in history:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "user":
messages.append(HumanMessage(content=content))
elif role == "assistant":
messages.append(AIMessage(content=content))
messages.append(HumanMessage(content=user_message))
return messages
async def astream(
self, message: str, history: list[dict[str, Any]]
) -> AsyncGenerator[dict[str, Any], None]:
"""Stream agent response with token-level and node-level events.
Yields events:
{"type": "retrieval", "query": "...", "results": [...]} (RAG mode only)
{"type": "token", "content": "..."}
{"type": "tool_start", "tool": "...", "input": "..."}
{"type": "tool_end", "tool": "...", "output": "..."}
{"type": "done", "content": "..."}
"""
# RAG retrieval: inject memory context if enabled
rag_mode = get_rag_mode()
rag_context = ""
if rag_mode and self._base_dir:
from graph.memory_indexer import get_memory_indexer
indexer = get_memory_indexer(self._base_dir)
results = indexer.retrieve(message)
if results:
yield {
"type": "retrieval",
"query": message,
"results": results,
}
snippets = "\n\n".join(
f"[片段 {i+1}] (score: {r['score']})\n{r['text']}"
for i, r in enumerate(results)
)
rag_context = f"[记忆检索结果]\n{snippets}"
agent = self._build_agent()
# Build messages with optional RAG context appended to history
augmented_history = list(history)
if rag_context:
augmented_history.append(
{"role": "assistant", "content": rag_context}
)
messages = self._build_messages(message, augmented_history)
full_response = ""
tools_just_finished = False
async for event in agent.astream(
{"messages": messages},
stream_mode=["messages", "updates"],
):
# event is a tuple of (stream_mode, data) when using multiple modes
if isinstance(event, tuple):
mode, data = event
else:
mode = "messages"
data = event
if mode == "messages":
# Token-level streaming from LLM
msg, metadata = data
if hasattr(msg, "content") and msg.content:
if msg.type == "AIMessageChunk" or msg.type == "ai":
if msg.content and not getattr(msg, "tool_calls", None):
# If tools just finished, signal a new response segment
if tools_just_finished:
yield {"type": "new_response"}
tools_just_finished = False
full_response += msg.content
yield {"type": "token", "content": msg.content}
elif mode == "updates":
if isinstance(data, dict):
for node_name, node_data in data.items():
if node_name == "tools" and "messages" in node_data:
for tool_msg in node_data["messages"]:
if hasattr(tool_msg, "name"):
yield {
"type": "tool_end",
"tool": tool_msg.name,
"output": str(tool_msg.content)[:2000],
}
# After all tool results, mark that tools finished
tools_just_finished = True
elif node_name == "model" and "messages" in node_data:
for agent_msg in node_data["messages"]:
if hasattr(agent_msg, "tool_calls") and agent_msg.tool_calls:
for tc in agent_msg.tool_calls:
yield {
"type": "tool_start",
"tool": tc["name"],
"input": str(tc.get("args", ""))[:1000],
}
yield {"type": "done", "content": full_response}
async def ainvoke(self, message: str, session_id: str) -> str:
"""Non-streaming invocation (fallback)."""
history = session_manager.load_session(session_id)
agent = self._build_agent()
messages = self._build_messages(message, history)
result = await agent.ainvoke({"messages": messages})
final_messages = result.get("messages", [])
for msg in reversed(final_messages):
if hasattr(msg, "content") and msg.type == "ai" and msg.content:
response = msg.content
session_manager.save_message(session_id, "user", message)
session_manager.save_message(session_id, "assistant", response)
return response
return "No response generated."
agent_manager = AgentManager()
+165
View File
@@ -0,0 +1,165 @@
"""MemoryIndexer — Vector index for MEMORY.md with auto-rebuild on change."""
import hashlib
import os
from pathlib import Path
from typing import Any
class MemoryIndexer:
"""Indexes memory/MEMORY.md for RAG retrieval.
Uses MD5 hash to detect changes and auto-rebuild the vector index.
Storage is kept separate from the knowledge base index.
"""
def __init__(self, base_dir: Path) -> None:
self._base_dir = base_dir
self._memory_path = base_dir / "memory" / "MEMORY.md"
self._storage_dir = base_dir / "storage" / "memory_index"
self._hash_path = self._storage_dir / ".memory_hash"
self._index: Any = None
def _get_file_hash(self) -> str:
"""Get MD5 hash of MEMORY.md."""
if not self._memory_path.exists():
return ""
content = self._memory_path.read_bytes()
return hashlib.md5(content).hexdigest()
def _get_stored_hash(self) -> str:
"""Get the stored hash from the last build."""
if not self._hash_path.exists():
return ""
return self._hash_path.read_text(encoding="utf-8").strip()
def _save_hash(self, hash_value: str) -> None:
"""Save the current hash."""
self._hash_path.parent.mkdir(parents=True, exist_ok=True)
self._hash_path.write_text(hash_value, encoding="utf-8")
def _maybe_rebuild(self) -> None:
"""Rebuild index if MEMORY.md has changed."""
current_hash = self._get_file_hash()
stored_hash = self._get_stored_hash()
if current_hash and current_hash != stored_hash:
self.rebuild_index()
def rebuild_index(self) -> None:
"""Read MEMORY.md, split into chunks, build vector index, persist."""
if not self._memory_path.exists():
print("⚠️ memory/MEMORY.md not found, skipping index build")
self._index = None
return
try:
from llama_index.core import (
Document,
StorageContext,
VectorStoreIndex,
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
# 使用 HuggingFace 镜像加速模型下载
if "HF_ENDPOINT" not in os.environ:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
Settings.embed_model = HuggingFaceEmbedding(
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
)
content = self._memory_path.read_text(encoding="utf-8")
if not content.strip():
self._index = None
return
doc = Document(text=content, metadata={"source": "MEMORY.md"})
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=32)
nodes = splitter.get_nodes_from_documents([doc])
self._storage_dir.mkdir(parents=True, exist_ok=True)
index = VectorStoreIndex(nodes)
index.storage_context.persist(persist_dir=str(self._storage_dir))
self._index = index
# Save hash
self._save_hash(self._get_file_hash())
print(f"🔄 Memory index rebuilt ({len(nodes)} chunks)")
except ImportError as e:
print(f"⚠️ LlamaIndex not fully installed: {e}")
self._index = None
except Exception as e:
print(f"⚠️ Memory index build error: {e}")
self._index = None
def _load_index(self) -> Any:
"""Load persisted index from storage."""
if self._index is not None:
return self._index
if not self._storage_dir.exists() or not any(self._storage_dir.iterdir()):
return None
try:
from llama_index.core import StorageContext, load_index_from_storage
from llama_index.core.settings import Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
# 使用 HuggingFace 镜像加速模型下载
if "HF_ENDPOINT" not in os.environ:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
Settings.embed_model = HuggingFaceEmbedding(
model_name=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh"),
)
storage_context = StorageContext.from_defaults(
persist_dir=str(self._storage_dir)
)
self._index = load_index_from_storage(storage_context)
return self._index
except Exception as e:
print(f"⚠️ Failed to load memory index: {e}")
return None
def retrieve(
self, query: str, top_k: int = 3
) -> list[dict[str, Any]]:
"""Retrieve relevant memory chunks for a query."""
self._maybe_rebuild()
index = self._load_index()
if index is None:
return []
try:
retriever = index.as_retriever(similarity_top_k=top_k)
nodes = retriever.retrieve(query)
results: list[dict[str, Any]] = []
for node in nodes:
results.append({
"text": node.get_text(),
"score": f"{node.get_score():.4f}" if node.get_score() else "N/A",
"source": node.metadata.get("source", "MEMORY.md"),
})
return results
except Exception as e:
print(f"⚠️ Memory retrieval error: {e}")
return []
# Singleton
_instance: MemoryIndexer | None = None
def get_memory_indexer(base_dir: Path) -> MemoryIndexer:
"""Get or create the singleton MemoryIndexer."""
global _instance
if _instance is None:
_instance = MemoryIndexer(base_dir)
return _instance
+60
View File
@@ -0,0 +1,60 @@
"""Prompt Builder — Assemble system prompt from 6 Markdown files."""
from pathlib import Path
MAX_COMPONENT_LENGTH = 20000
def _read_component(path: Path) -> str:
"""Read a file, truncating if it exceeds MAX_COMPONENT_LENGTH."""
if not path.exists():
return ""
content = path.read_text(encoding="utf-8")
if len(content) > MAX_COMPONENT_LENGTH:
content = content[:MAX_COMPONENT_LENGTH] + "\n...[truncated]"
return content
RAG_GUIDANCE = """注意:长期记忆(MEMORY.md)已切换为RAG检索模式。
系统会根据用户的问题自动检索相关记忆片段并注入上下文。
如果检索到了相关记忆,它们会以"[记忆检索结果]"标记呈现在你的上下文中。"""
def build_system_prompt(base_dir: Path, rag_mode: bool = False) -> str:
"""Build the full system prompt by concatenating components in order.
Order:
1. SKILLS_SNAPSHOT.md — available skills listing
2. workspace/SOUL.md — persona, tone, boundaries
3. workspace/IDENTITY.md — name, style, emoji
4. workspace/USER.md — user profile
5. workspace/AGENTS.md — operation instructions & memory/skill protocols
6. memory/MEMORY.md — cross-session long-term memory (skipped in RAG mode)
When rag_mode=True, MEMORY.md is excluded and a RAG guidance note is appended.
"""
components = [
("Skills Snapshot", base_dir / "SKILLS_SNAPSHOT.md"),
("Soul", base_dir / "workspace" / "SOUL.md"),
("Identity", base_dir / "workspace" / "IDENTITY.md"),
("User Profile", base_dir / "workspace" / "USER.md"),
("Agents Guide", base_dir / "workspace" / "AGENTS.md"),
]
# Only include MEMORY.md when not in RAG mode
if not rag_mode:
components.append(
("Long-term Memory", base_dir / "memory" / "MEMORY.md")
)
parts: list[str] = []
for label, path in components:
content = _read_component(path)
if content:
parts.append(f"<!-- {label} -->\n{content}")
# Append RAG guidance when in RAG mode
if rag_mode:
parts.append(f"<!-- RAG Mode -->\n{RAG_GUIDANCE}")
return "\n\n".join(parts)
+242
View File
@@ -0,0 +1,242 @@
"""Session Manager — JSON file-based conversation persistence with metadata."""
import json
import time
from pathlib import Path
from typing import Any
class SessionManager:
"""Manages conversation history as JSON files in sessions/ directory.
Storage format (v2):
{
"title": "会话标题",
"created_at": 1706000000,
"updated_at": 1706000100,
"messages": [{"role": "user", "content": "..."}, ...]
}
"""
def __init__(self) -> None:
self._sessions_dir: Path | None = None
def initialize(self, base_dir: Path) -> None:
self._sessions_dir = base_dir / "sessions"
self._sessions_dir.mkdir(exist_ok=True)
def _session_path(self, session_id: str) -> Path:
assert self._sessions_dir is not None
safe_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
return self._sessions_dir / f"{safe_id}.json"
def _read_file(self, session_id: str) -> dict[str, Any]:
"""Read session file and normalize to v2 format."""
path = self._session_path(session_id)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
# Migrate v1 (plain list) to v2 (object with metadata)
if isinstance(data, list):
now = time.time()
return {
"title": session_id,
"created_at": path.stat().st_ctime,
"updated_at": now,
"messages": data,
}
return data
except (json.JSONDecodeError, Exception):
return {}
def _write_file(self, session_id: str, data: dict[str, Any]) -> None:
"""Write session data to file."""
data["updated_at"] = time.time()
path = self._session_path(session_id)
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def create_session(self, session_id: str) -> dict[str, Any]:
"""Create a new empty session. Returns metadata."""
now = time.time()
data: dict[str, Any] = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
self._write_file(session_id, data)
return {"id": session_id, "title": data["title"], "created_at": now, "updated_at": now}
def load_session(self, session_id: str) -> list[dict[str, Any]]:
"""Load conversation history for a session."""
data = self._read_file(session_id)
if not data:
return []
return data.get("messages", [])
def save_message(
self,
session_id: str,
role: str,
content: str,
tool_calls: list[dict[str, Any]] | None = None,
) -> None:
"""Append a message to the session history."""
data = self._read_file(session_id)
if not data:
now = time.time()
data = {
"title": "New Chat",
"created_at": now,
"updated_at": now,
"messages": [],
}
msg: dict[str, Any] = {"role": role, "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
data["messages"].append(msg)
self._write_file(session_id, data)
def rename_session(self, session_id: str, title: str) -> None:
"""Rename a session."""
data = self._read_file(session_id)
if not data:
raise FileNotFoundError(f"Session {session_id} not found")
data["title"] = title
self._write_file(session_id, data)
def update_title(self, session_id: str, title: str) -> None:
"""Update session title (alias for rename_session)."""
self.rename_session(session_id, title)
def delete_session(self, session_id: str) -> None:
"""Delete a session file."""
path = self._session_path(session_id)
if path.exists():
path.unlink()
def get_raw_messages(self, session_id: str) -> dict[str, Any]:
"""Return the complete session data including all messages."""
data = self._read_file(session_id)
if not data:
return {"title": "", "messages": []}
return data
def list_sessions(self) -> list[dict[str, Any]]:
"""List all sessions with metadata."""
assert self._sessions_dir is not None
sessions: list[dict[str, Any]] = []
for f in sorted(self._sessions_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
raw = json.loads(f.read_text(encoding="utf-8"))
if isinstance(raw, dict):
title = raw.get("title", f.stem)
updated_at = raw.get("updated_at", f.stat().st_mtime)
else:
title = f.stem
updated_at = f.stat().st_mtime
except Exception:
title = f.stem
updated_at = f.stat().st_mtime
sessions.append({
"id": f.stem,
"title": title,
"updated_at": updated_at,
})
return sessions
def compress_history(
self, session_id: str, summary: str, num_to_remove: int
) -> None:
"""Archive first N messages and store summary as compressed_context."""
assert self._sessions_dir is not None
data = self._read_file(session_id)
if not data:
return
messages = data.get("messages", [])
archived_messages = messages[:num_to_remove]
# Archive to sessions/archive/
archive_dir = self._sessions_dir / "archive"
archive_dir.mkdir(exist_ok=True)
archive_data = {
"session_id": session_id,
"archived_at": time.time(),
"messages": archived_messages,
}
archive_path = archive_dir / f"{session_id}_{int(time.time())}.json"
archive_path.write_text(
json.dumps(archive_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Remove archived messages from session
data["messages"] = messages[num_to_remove:]
# Append summary to compressed_context (support multiple compressions)
existing_context = data.get("compressed_context", "")
if existing_context:
data["compressed_context"] = existing_context + "\n---\n" + summary
else:
data["compressed_context"] = summary
self._write_file(session_id, data)
def get_compressed_context(self, session_id: str) -> str | None:
"""Return compressed context if any."""
data = self._read_file(session_id)
if not data:
return None
return data.get("compressed_context")
def load_session_for_agent(self, session_id: str) -> list[dict[str, Any]]:
"""Load session history merged for LLM context.
Since we now save multiple consecutive assistant messages per turn,
this method combines them back into single assistant messages to
maintain proper user/assistant alternation for the LLM.
If compressed_context exists, inserts it at the head as an assistant
message so the LLM retains prior context.
"""
data = self._read_file(session_id)
messages = data.get("messages", []) if data else []
merged: list[dict[str, Any]] = []
# Inject compressed context as the first message if available
compressed = data.get("compressed_context", "") if data else ""
if compressed:
merged.append({
"role": "assistant",
"content": f"[以下是之前对话的摘要]\n{compressed}",
})
for msg in messages:
if (
merged
and merged[-1]["role"] == "assistant"
and msg["role"] == "assistant"
):
# Combine consecutive assistant messages
merged[-1]["content"] += "\n" + msg["content"]
else:
# Strip tool_calls for agent context (not needed by LLM)
merged.append({"role": msg["role"], "content": msg["content"]})
return merged
def get_message_count(self, session_id: str) -> int:
"""Return the number of messages in a session."""
data = self._read_file(session_id)
if not data:
return 0
return len(data.get("messages", []))
session_manager = SessionManager()