feat: 初始化项目基础结构
添加项目基础文件和目录结构,包括: - 初始化空包目录(bot/agent/orchestrator) - 配置文件(config.py)和示例(keyring.example.yaml) - 依赖文件(requirements.txt) - 主程序入口(main.py) - 调试脚本(debug_test.py) - 文档说明(README.md) - Git忽略文件(.gitignore) - 核心功能模块(pty_process/manager/handler/feishu等)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# empty init
|
||||
@@ -0,0 +1,150 @@
|
||||
"""LangChain orchestration agent backed by ZhipuAI (OpenAI-compatible API).
|
||||
|
||||
Uses LangChain 1.x tool-calling pattern: bind_tools + manual agentic loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR
|
||||
from orchestrator.tools import TOOLS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """You are PhoneWork, an AI assistant that helps users control Claude Code \
|
||||
from their phone via Feishu (飞书).
|
||||
|
||||
You manage Claude Code sessions. Each session has a conv_id and runs in a project directory.
|
||||
|
||||
Base working directory: {working_dir}
|
||||
Users refer to projects by subfolder name (e.g. "todo_app") or relative path. \
|
||||
Pass these names directly to `create_conversation` — the tool resolves them automatically.
|
||||
|
||||
{active_session_line}
|
||||
|
||||
Your responsibilities:
|
||||
1. NEW session: call `create_conversation` with the project name/path. \
|
||||
If the user's message also contains a task, pass it as `initial_message` too.
|
||||
2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
|
||||
3. List sessions: call `list_conversations`.
|
||||
4. Close session: call `close_conversation`.
|
||||
|
||||
Guidelines:
|
||||
- Relay Claude Code's output verbatim.
|
||||
- If no active session and the user sends a task without naming a directory, ask them which project.
|
||||
- Keep your own words brief — let Claude Code's output speak.
|
||||
- Reply in the same language the user uses (Chinese or English).
|
||||
"""
|
||||
|
||||
MAX_ITERATIONS = 10
|
||||
_TOOL_MAP = {t.name: t for t in TOOLS}
|
||||
|
||||
|
||||
class OrchestrationAgent:
|
||||
"""Per-user agent with conversation history and active session tracking."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
llm = ChatOpenAI(
|
||||
base_url=OPENAI_BASE_URL,
|
||||
api_key=OPENAI_API_KEY,
|
||||
model=OPENAI_MODEL,
|
||||
temperature=0.0,
|
||||
)
|
||||
self._llm_with_tools = llm.bind_tools(TOOLS)
|
||||
|
||||
# user_id -> list[BaseMessage]
|
||||
self._history: Dict[str, List[BaseMessage]] = defaultdict(list)
|
||||
# user_id -> most recently active conv_id
|
||||
self._active_conv: Dict[str, Optional[str]] = defaultdict(lambda: None)
|
||||
|
||||
def _build_system_prompt(self, user_id: str) -> str:
|
||||
conv_id = self._active_conv[user_id]
|
||||
if conv_id:
|
||||
active_line = f"ACTIVE SESSION: conv_id={conv_id!r} ← use this for all follow-up messages"
|
||||
else:
|
||||
active_line = "ACTIVE SESSION: none"
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
working_dir=WORKING_DIR,
|
||||
active_session_line=active_line,
|
||||
)
|
||||
|
||||
async def run(self, user_id: str, text: str) -> str:
|
||||
"""Process a user message and return the agent's reply."""
|
||||
messages: List[BaseMessage] = (
|
||||
[SystemMessage(content=self._build_system_prompt(user_id))]
|
||||
+ self._history[user_id]
|
||||
+ [HumanMessage(content=text)]
|
||||
)
|
||||
|
||||
reply = ""
|
||||
try:
|
||||
for _ in range(MAX_ITERATIONS):
|
||||
ai_msg: AIMessage = await self._llm_with_tools.ainvoke(messages)
|
||||
messages.append(ai_msg)
|
||||
|
||||
if not ai_msg.tool_calls:
|
||||
reply = ai_msg.content or ""
|
||||
break
|
||||
|
||||
for tc in ai_msg.tool_calls:
|
||||
tool_name = tc["name"]
|
||||
tool_args = tc["args"]
|
||||
tool_id = tc["id"]
|
||||
|
||||
tool_obj = _TOOL_MAP.get(tool_name)
|
||||
if tool_obj is None:
|
||||
result = f"Unknown tool: {tool_name}"
|
||||
else:
|
||||
try:
|
||||
result = await tool_obj.arun(tool_args)
|
||||
except Exception as exc:
|
||||
result = f"Tool error: {exc}"
|
||||
|
||||
# If a session was just created, record it as the active session
|
||||
if tool_name == "create_conversation":
|
||||
try:
|
||||
data = json.loads(result)
|
||||
if "conv_id" in data:
|
||||
self._active_conv[user_id] = data["conv_id"]
|
||||
logger.info(
|
||||
"Active session for %s set to %s",
|
||||
user_id, data["conv_id"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
messages.append(
|
||||
ToolMessage(content=str(result), tool_call_id=tool_id)
|
||||
)
|
||||
else:
|
||||
reply = "[Max iterations reached]"
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Agent error for user %s", user_id)
|
||||
reply = f"[Error] {exc}"
|
||||
|
||||
# Update history
|
||||
self._history[user_id].append(HumanMessage(content=text))
|
||||
self._history[user_id].append(AIMessage(content=reply))
|
||||
|
||||
if len(self._history[user_id]) > 40:
|
||||
self._history[user_id] = self._history[user_id][-40:]
|
||||
|
||||
return reply
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
agent = OrchestrationAgent()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""LangChain tools that bridge the orchestration agent to Claude Code PTY sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.manager import manager
|
||||
from config import WORKING_DIR
|
||||
|
||||
|
||||
def _resolve_dir(working_dir: str) -> Path:
|
||||
"""
|
||||
Resolve working_dir to an absolute path under WORKING_DIR.
|
||||
|
||||
Rules:
|
||||
- Absolute paths are used as-is (but must stay within WORKING_DIR for safety).
|
||||
- Relative paths / bare names are joined onto WORKING_DIR.
|
||||
- The resolved directory is created if it doesn't exist.
|
||||
"""
|
||||
p = Path(working_dir)
|
||||
if not p.is_absolute():
|
||||
p = WORKING_DIR / p
|
||||
p = p.resolve()
|
||||
|
||||
# Safety: must be inside WORKING_DIR
|
||||
try:
|
||||
p.relative_to(WORKING_DIR)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Directory {p} is outside the allowed base {WORKING_DIR}. "
|
||||
"Please use a subfolder name or a path inside the working directory."
|
||||
)
|
||||
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateConversationInput(BaseModel):
|
||||
working_dir: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"The project directory. Can be a subfolder name (e.g. 'todo_app'), "
|
||||
"a relative path (e.g. 'projects/todo_app'), or a full absolute path. "
|
||||
"Relative names are resolved under the configured base working directory."
|
||||
),
|
||||
)
|
||||
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
|
||||
|
||||
|
||||
class SendToConversationInput(BaseModel):
|
||||
conv_id: str = Field(..., description="Conversation ID returned by create_conversation")
|
||||
message: str = Field(..., description="Message / instruction to send to Claude Code")
|
||||
|
||||
|
||||
class CloseConversationInput(BaseModel):
|
||||
conv_id: str = Field(..., description="Conversation ID to close")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateConversationTool(BaseTool):
|
||||
name: str = "create_conversation"
|
||||
description: str = (
|
||||
"Spawn a new Claude Code session in the given working directory. "
|
||||
"Returns a conv_id that must be used for subsequent messages. "
|
||||
"Use this when the user wants to start a new task in a specific directory."
|
||||
)
|
||||
args_schema: Type[BaseModel] = CreateConversationInput
|
||||
|
||||
def _run(self, working_dir: str, initial_message: Optional[str] = None) -> str:
|
||||
raise NotImplementedError("Use async version")
|
||||
|
||||
async def _arun(self, working_dir: str, initial_message: Optional[str] = None) -> str:
|
||||
try:
|
||||
resolved = _resolve_dir(working_dir)
|
||||
except ValueError as exc:
|
||||
return json.dumps({"error": str(exc)})
|
||||
|
||||
conv_id = str(uuid.uuid4())[:8]
|
||||
await manager.create(conv_id, str(resolved))
|
||||
|
||||
result: dict = {
|
||||
"conv_id": conv_id,
|
||||
"working_dir": str(resolved),
|
||||
}
|
||||
|
||||
if initial_message:
|
||||
output = await manager.send(conv_id, initial_message)
|
||||
result["response"] = output
|
||||
else:
|
||||
result["status"] = "Session created. Send a message to start working."
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
class SendToConversationTool(BaseTool):
|
||||
name: str = "send_to_conversation"
|
||||
description: str = (
|
||||
"Send a message to an existing Claude Code session and return its response. "
|
||||
"Use this for follow-up messages in an ongoing session."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendToConversationInput
|
||||
|
||||
def _run(self, conv_id: str, message: str) -> str:
|
||||
raise NotImplementedError("Use async version")
|
||||
|
||||
async def _arun(self, conv_id: str, message: str) -> str:
|
||||
try:
|
||||
output = await manager.send(conv_id, message)
|
||||
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
|
||||
except KeyError:
|
||||
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
|
||||
|
||||
|
||||
class ListConversationsTool(BaseTool):
|
||||
name: str = "list_conversations"
|
||||
description: str = "List all currently active Claude Code sessions."
|
||||
|
||||
def _run(self) -> str:
|
||||
raise NotImplementedError("Use async version")
|
||||
|
||||
async def _arun(self) -> str:
|
||||
sessions = manager.list_sessions()
|
||||
if not sessions:
|
||||
return "No active sessions."
|
||||
return json.dumps(sessions, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class CloseConversationTool(BaseTool):
|
||||
name: str = "close_conversation"
|
||||
description: str = "Close and terminate an active Claude Code session."
|
||||
args_schema: Type[BaseModel] = CloseConversationInput
|
||||
|
||||
def _run(self, conv_id: str) -> str:
|
||||
raise NotImplementedError("Use async version")
|
||||
|
||||
async def _arun(self, conv_id: str) -> str:
|
||||
closed = await manager.close(conv_id)
|
||||
if closed:
|
||||
return f"Session {conv_id!r} closed."
|
||||
return f"Session {conv_id!r} not found."
|
||||
|
||||
|
||||
# Module-level tool list for easy import
|
||||
TOOLS = [
|
||||
CreateConversationTool(),
|
||||
SendToConversationTool(),
|
||||
ListConversationsTool(),
|
||||
CloseConversationTool(),
|
||||
]
|
||||
Reference in New Issue
Block a user