feat: 添加任务调度器、后台任务运行器及多种工具支持

实现后台任务调度器(scheduler.py)和任务运行器(task_runner.py),支持长时间运行任务的异步执行和状态跟踪
新增多种工具支持:Shell命令执行、文件操作(读写/搜索/发送)、网页搜索/问答、定时提醒等
扩展README和ROADMAP文档,描述新功能和未来多主机架构规划
在配置文件中添加METASO_API_KEY支持秘塔AI搜索功能
优化代理逻辑,自动识别通用问题直接回答而不创建会话
This commit is contained in:
Yuyao Huang (Sam)
2026-03-28 13:45:20 +08:00
parent 6a0d409dd6
commit 8ecc701d5e
13 changed files with 1499 additions and 7 deletions
+50
View File
@@ -44,10 +44,14 @@ Your responsibilities:
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`.
5. GENERAL QUESTIONS: If the user asks a general question (not about a specific project or file), \
answer directly using your own knowledge. Do NOT create a session for simple Q&A.
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.
- For general knowledge questions (e.g., "what is a Python generator?", "explain async/await"), \
answer directly without creating a session.
- Keep your own words brief — let Claude Code's output speak.
- Reply in the same language the user uses (Chinese or English).
"""
@@ -55,6 +59,35 @@ Guidelines:
MAX_ITERATIONS = 10
_TOOL_MAP = {t.name: t for t in TOOLS}
QUESTION_PATTERNS = [
r'\?$', # ends with ?
r'$', # ends with Chinese ?
r'\b(what|how|why|when|where|who|which|explain|describe|tell me|can you|could you|is there|are there|do you know)\b',
r'(什么|怎么|为什么|何时|哪里|谁|哪个|解释|描述|告诉我|能否|可以|有没有|是不是)',
]
def _is_general_question(text: str) -> bool:
"""Check if text looks like a general knowledge question (not a project task)."""
text_lower = text.lower().strip()
project_indicators = [
'create', 'make', 'build', 'fix', 'update', 'delete', 'remove', 'add',
'implement', 'refactor', 'test', 'run', 'execute', 'start', 'stop',
'project', 'folder', 'directory', 'file', 'code', 'session',
'创建', '制作', '构建', '修复', '更新', '删除', '添加', '实现', '重构', '测试', '运行', '项目', '文件夹', '文件', '代码',
]
for indicator in project_indicators:
if indicator in text_lower:
return False
for pattern in QUESTION_PATTERNS:
if re.search(pattern, text_lower, re.IGNORECASE):
return True
return False
class OrchestrationAgent:
"""Per-user agent with conversation history and active session tracking."""
@@ -123,6 +156,23 @@ class OrchestrationAgent:
logger.exception("Passthrough error for user=%s", user_id)
return f"[Error] {exc}"
# Direct Q&A: if no active session and message looks like a general question, answer directly
if not active_conv and _is_general_question(text):
logger.debug(" → direct Q&A (no tools)")
llm_no_tools = ChatOpenAI(
base_url=OPENAI_BASE_URL,
api_key=OPENAI_API_KEY,
model=OPENAI_MODEL,
temperature=0.7,
)
qa_prompt = (
"You are a helpful assistant. Answer the user's question concisely and accurately. "
"Reply in the same language the user uses.\n\n"
f"Question: {text}"
)
response = await llm_no_tools.ainvoke([HumanMessage(content=qa_prompt)])
return response.content or ""
messages: List[BaseMessage] = (
[SystemMessage(content=self._build_system_prompt(user_id))]
+ self._history[user_id]
+515
View File
@@ -15,6 +15,7 @@ from agent.manager import manager
from config import WORKING_DIR
_current_user_id: ContextVar[Optional[str]] = ContextVar("current_user_id", default=None)
_current_chat_id: ContextVar[Optional[str]] = ContextVar("current_chat_id", default=None)
def set_current_user(user_id: Optional[str]) -> None:
@@ -25,6 +26,14 @@ def get_current_user() -> Optional[str]:
return _current_user_id.get()
def set_current_chat(chat_id: Optional[str]) -> None:
_current_chat_id.set(chat_id)
def get_current_chat() -> Optional[str]:
return _current_chat_id.get()
def _resolve_dir(working_dir: str) -> Path:
"""
Resolve working_dir to an absolute path under WORKING_DIR.
@@ -188,10 +197,516 @@ class CloseConversationTool(BaseTool):
return str(e)
BLOCKED_PATTERNS = [
r'\brm\s+-rf\s+/',
r'\brm\s+-rf\s+~',
r'\bformat\s+',
r'\bmkfs\b',
r'\bshutdown\b',
r'\breboot\b',
r'\bdd\s+if=',
r':\(\)\{:\|:&\};:',
r'\bchmod\s+777\s+/',
r'\bchown\s+.*\s+/',
r'\b>\s*/dev/sd',
r'\bkill\s+-9\s+1\b',
r'\bsudo\s+rm\b',
r'\bsu\s+-c\b',
r'\bsudo\s+chmod\b',
r'\bsudo\s+chown\b',
r'\bsudo\s+dd\b',
r'\b>\s*/dev/null\s+2>&1\s*&\s*;', # fork via backgrounding
]
def _is_command_safe(command: str) -> tuple[bool, str]:
"""Check if command is safe to execute."""
import re
for pattern in BLOCKED_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return False, f"Blocked: command matches dangerous pattern"
return True, ""
class ShellInput(BaseModel):
command: str = Field(..., description="Shell command to execute")
cwd: Optional[str] = Field(None, description="Working directory (default: WORKING_DIR)")
timeout: Optional[int] = Field(30, description="Timeout in seconds (max 120)")
class ShellTool(BaseTool):
name: str = "run_shell"
description: str = (
"Execute a shell command on the host machine and return stdout/stderr. "
"Use for: git status, ls, cat, grep, pip list, etc. "
"Destructive commands (rm -rf /, format, shutdown) are blocked."
)
args_schema: Type[BaseModel] = ShellInput
def _run(self, command: str, cwd: Optional[str] = None, timeout: Optional[int] = 30) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, command: str, cwd: Optional[str] = None, timeout: Optional[int] = 30) -> str:
import asyncio
import shutil
is_safe, reason = _is_command_safe(command)
if not is_safe:
return json.dumps({"error": reason}, ensure_ascii=False)
timeout = min(timeout or 30, 120)
work_dir = WORKING_DIR
if cwd:
try:
work_dir = _resolve_dir(cwd)
except ValueError as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
try:
proc = await asyncio.create_subprocess_shell(
command,
cwd=str(work_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout,
)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:4000],
"stderr": stderr.decode("utf-8", errors="replace")[:1000],
"exit_code": proc.returncode,
"cwd": str(work_dir),
}, ensure_ascii=False)
except asyncio.TimeoutError:
return json.dumps({"error": f"Command timed out after {timeout}s"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class FileReadInput(BaseModel):
path: str = Field(..., description="File path relative to working directory")
start_line: Optional[int] = Field(None, description="Start line (1-indexed)")
end_line: Optional[int] = Field(None, description="End line (inclusive)")
class FileReadTool(BaseTool):
name: str = "read_file"
description: str = "Read a file from the working directory. Returns file content with line numbers."
args_schema: Type[BaseModel] = FileReadInput
def _run(self, path: str, start_line: Optional[int] = None, end_line: Optional[int] = None) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, path: str, start_line: Optional[int] = None, end_line: Optional[int] = None) -> str:
try:
file_path = _resolve_dir(path)
if not file_path.is_file():
return json.dumps({"error": f"Not a file: {path}"}, ensure_ascii=False)
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
total_lines = len(lines)
start = max(1, start_line or 1) - 1
end = min(total_lines, end_line or total_lines)
result_lines = []
for i in range(start, end):
result_lines.append(f"{i+1:4d} | {lines[i].rstrip()}")
return json.dumps({
"path": str(file_path),
"lines": f"{start+1}-{end}",
"total_lines": total_lines,
"content": "\n".join(result_lines[-500:]),
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class FileWriteInput(BaseModel):
path: str = Field(..., description="File path relative to working directory")
content: str = Field(..., description="Content to write")
mode: Optional[str] = Field("overwrite", description="Write mode: 'overwrite' or 'append'")
class FileWriteTool(BaseTool):
name: str = "write_file"
description: str = "Write content to a file in the working directory. Use mode='append' to add to existing file."
args_schema: Type[BaseModel] = FileWriteInput
def _run(self, path: str, content: str, mode: Optional[str] = "overwrite") -> str:
raise NotImplementedError("Use async version")
async def _arun(self, path: str, content: str, mode: Optional[str] = "overwrite") -> str:
try:
file_path = _resolve_dir(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
write_mode = "a" if mode == "append" else "w"
with open(file_path, write_mode, encoding="utf-8") as f:
f.write(content)
return json.dumps({
"success": True,
"path": str(file_path),
"bytes_written": len(content.encode("utf-8")),
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class FileListInput(BaseModel):
path: Optional[str] = Field(None, description="Directory path (default: working directory)")
pattern: Optional[str] = Field(None, description="Glob pattern (e.g. '*.py')")
class FileListTool(BaseTool):
name: str = "list_files"
description: str = "List files in a directory. Use pattern to filter (e.g. '*.py')."
args_schema: Type[BaseModel] = FileListInput
def _run(self, path: Optional[str] = None, pattern: Optional[str] = None) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, path: Optional[str] = None, pattern: Optional[str] = None) -> str:
try:
dir_path = _resolve_dir(path or ".")
if not dir_path.is_dir():
return json.dumps({"error": f"Not a directory: {path}"}, ensure_ascii=False)
if pattern:
files = list(dir_path.glob(pattern))[:100]
else:
files = list(dir_path.iterdir())[:100]
result = []
for f in sorted(files):
result.append({
"name": f.name,
"type": "dir" if f.is_dir() else "file",
"size": f.stat().st_size if f.is_file() else None,
})
return json.dumps({
"path": str(dir_path),
"files": result,
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class FileSearchInput(BaseModel):
path: str = Field(..., description="Directory path to search in")
pattern: str = Field(..., description="Search pattern (regex supported)")
max_results: Optional[int] = Field(50, description="Max number of results")
class FileSearchTool(BaseTool):
name: str = "search_files"
description: str = (
"Search for text pattern in files under a directory (grep-like). "
"Returns matching lines with file paths and line numbers."
)
args_schema: Type[BaseModel] = FileSearchInput
def _run(self, path: str, pattern: str, max_results: Optional[int] = 50) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, path: str, pattern: str, max_results: Optional[int] = 50) -> str:
import re
try:
dir_path = _resolve_dir(path)
if not dir_path.is_dir():
return json.dumps({"error": f"Not a directory: {path}"}, ensure_ascii=False)
try:
regex = re.compile(pattern, re.IGNORECASE)
except re.error as e:
return json.dumps({"error": f"Invalid regex pattern: {e}"}, ensure_ascii=False)
results = []
text_extensions = {'.py', '.js', '.ts', '.tsx', '.jsx', '.java', '.c', '.cpp', '.h',
'.go', '.rs', '.rb', '.php', '.cs', '.swift', '.kt', '.scala',
'.txt', '.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
'.html', '.css', '.scss', '.sass', '.less', '.xml', '.sql',
'.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd'}
for file_path in dir_path.rglob("*"):
if not file_path.is_file():
continue
if file_path.suffix.lower() not in text_extensions:
continue
if any(part.startswith('.') for part in file_path.parts):
continue
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
rel_path = file_path.relative_to(dir_path)
results.append({
"file": str(rel_path),
"line": line_num,
"content": line.rstrip()[:200],
})
if len(results) >= max_results:
break
if len(results) >= max_results:
break
except Exception:
continue
return json.dumps({
"path": str(dir_path),
"pattern": pattern,
"total_matches": len(results),
"results": results,
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class FileSendInput(BaseModel):
path: str = Field(..., description="File path to send")
class FileSendTool(BaseTool):
name: str = "send_file"
description: str = "Send a file to the user via Feishu. Returns confirmation message."
args_schema: Type[BaseModel] = FileSendInput
def _run(self, path: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, path: str) -> str:
try:
file_path = _resolve_dir(path)
if not file_path.is_file():
return json.dumps({"error": f"Not a file: {path}"}, ensure_ascii=False)
chat_id = get_current_chat()
if not chat_id:
return json.dumps({"error": "No chat context available"}, ensure_ascii=False)
from bot.feishu import send_file
await send_file(chat_id, "chat_id", str(file_path))
return json.dumps({
"success": True,
"path": str(file_path),
"size": file_path.stat().st_size,
"message": f"File sent: {file_path.name}",
}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class WebInput(BaseModel):
action: str = Field(..., description="Action: 'search', 'fetch', or 'ask'")
query: Optional[str] = Field(None, description="Search query or question")
url: Optional[str] = Field(None, description="URL to fetch (for 'fetch' action)")
scope: Optional[str] = Field("webpage", description="Search scope: webpage, paper, document, video, podcast")
max_chars: Optional[int] = Field(2000, description="Max characters in response")
class WebTool(BaseTool):
name: str = "web"
description: str = (
"Search the web, fetch URLs, or ask questions using 秘塔AI Search. "
"Actions: 'search' (web search), 'fetch' (extract content from URL), 'ask' (RAG Q&A). "
"Requires METASO_API_KEY in keyring.yaml."
)
args_schema: Type[BaseModel] = WebInput
def _run(self, action: str, query: Optional[str] = None, url: Optional[str] = None,
scope: Optional[str] = "webpage", max_chars: Optional[int] = 2000) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, action: str, query: Optional[str] = None, url: Optional[str] = None,
scope: Optional[str] = "webpage", max_chars: Optional[int] = 2000) -> str:
from config import METASO_API_KEY
if not METASO_API_KEY:
return json.dumps({"error": "METASO_API_KEY not configured. Add it to keyring.yaml."}, ensure_ascii=False)
import httpx
base_url = "https://metaso.cn/api/mcp"
headers = {
"Authorization": f"Bearer {METASO_API_KEY}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
if action == "search":
if not query:
return json.dumps({"error": "query required for search"}, ensure_ascii=False)
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "metaso_web_search",
"params": {"query": query, "scope": scope or "webpage"},
}
resp = await client.post(base_url, json=payload, headers=headers)
data = resp.json()
if "error" in data:
return json.dumps({"error": data["error"]}, ensure_ascii=False)
results = data.get("result", {}).get("results", [])[:5]
output = []
for r in results:
output.append(f"**{r.get('title', 'No title')}**\n{r.get('snippet', '')}\n{r.get('url', '')}")
return json.dumps({"results": "\n\n".join(output)[:max_chars]}, ensure_ascii=False)
elif action == "fetch":
if not url:
return json.dumps({"error": "url required for fetch"}, ensure_ascii=False)
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "metaso_web_reader",
"params": {"url": url, "format": "markdown"},
}
resp = await client.post(base_url, json=payload, headers=headers)
data = resp.json()
if "error" in data:
return json.dumps({"error": data["error"]}, ensure_ascii=False)
content = data.get("result", {}).get("content", "")
return json.dumps({"content": content[:max_chars]}, ensure_ascii=False)
elif action == "ask":
if not query:
return json.dumps({"error": "query required for ask"}, ensure_ascii=False)
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "metaso_chat",
"params": {"query": query},
}
resp = await client.post(base_url, json=payload, headers=headers)
data = resp.json()
if "error" in data:
return json.dumps({"error": data["error"]}, ensure_ascii=False)
answer = data.get("result", {}).get("answer", "")
return json.dumps({"answer": answer[:max_chars]}, ensure_ascii=False)
else:
return json.dumps({"error": f"Unknown action: {action}"}, ensure_ascii=False)
except httpx.TimeoutException:
return json.dumps({"error": "Request timed out"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
class SchedulerInput(BaseModel):
action: str = Field(..., description="Action: 'remind' or 'repeat'")
delay_seconds: Optional[int] = Field(None, description="Delay in seconds (for 'remind')")
interval_seconds: Optional[int] = Field(None, description="Interval in seconds (for 'repeat')")
message: str = Field(..., description="Reminder message")
max_runs: Optional[int] = Field(5, description="Max runs for recurring (default 5)")
class SchedulerTool(BaseTool):
name: str = "scheduler"
description: str = (
"Schedule reminders. Use 'remind' for one-time, 'repeat' for recurring. "
"Notifications sent to current chat."
)
args_schema: Type[BaseModel] = SchedulerInput
def _run(self, action: str, message: str, delay_seconds: Optional[int] = None,
interval_seconds: Optional[int] = None, max_runs: Optional[int] = 5) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, action: str, message: str, delay_seconds: Optional[int] = None,
interval_seconds: Optional[int] = None, max_runs: Optional[int] = 5) -> str:
from agent.scheduler import scheduler
chat_id = get_current_chat()
if action == "remind":
if not delay_seconds:
return json.dumps({"error": "delay_seconds required for remind"}, ensure_ascii=False)
job_id = await scheduler.schedule_once(
delay_seconds=delay_seconds,
message=message,
notify_chat_id=chat_id,
)
return json.dumps({
"success": True,
"job_id": job_id,
"message": f"Reminder set for {delay_seconds}s from now",
}, ensure_ascii=False)
elif action == "repeat":
if not interval_seconds:
return json.dumps({"error": "interval_seconds required for repeat"}, ensure_ascii=False)
job_id = await scheduler.schedule_recurring(
interval_seconds=interval_seconds,
message=message,
max_runs=max_runs or 5,
notify_chat_id=chat_id,
)
return json.dumps({
"success": True,
"job_id": job_id,
"message": f"Recurring reminder set every {interval_seconds}s ({max_runs} times)",
}, ensure_ascii=False)
else:
return json.dumps({"error": f"Unknown action: {action}"}, ensure_ascii=False)
class TaskStatusInput(BaseModel):
task_id: str = Field(..., description="Task ID to check")
class TaskStatusTool(BaseTool):
name: str = "task_status"
description: str = "Check the status of a background task. Returns current status and result if completed."
args_schema: Type[BaseModel] = TaskStatusInput
def _run(self, task_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, task_id: str) -> str:
from agent.task_runner import task_runner
task = task_runner.get_task(task_id)
if not task:
return json.dumps({"error": f"Task {task_id} not found"}, ensure_ascii=False)
return json.dumps({
"task_id": task.task_id,
"description": task.description,
"status": task.status.value,
"elapsed": int(task.elapsed),
"started_at": task.started_at,
"completed_at": task.completed_at,
"result": task.result[:500] if task.result else None,
"error": task.error,
}, ensure_ascii=False)
# Module-level tool list for easy import
TOOLS = [
CreateConversationTool(),
SendToConversationTool(),
ListConversationsTool(),
CloseConversationTool(),
ShellTool(),
FileReadTool(),
FileWriteTool(),
FileListTool(),
FileSearchTool(),
FileSendTool(),
WebTool(),
SchedulerTool(),
TaskStatusTool(),
]