Files
aiagent/backend/app/services/builtin_tools.py
renjianbo d895922438 fix: inject agent_id into system prompt for all WS handlers + enhance schedule_delete
- All 5 WS handlers (lingxi/feishu/orange/suyao/tiantian) now inject agent_id
  into LLM system prompt so agents know their own ID for schedule_list calls
- schedule_delete_tool now supports agent_id parameter for ownership checks
  and bulk delete by agent_id
- SCHEDULE_DELETE_SCHEMA updated: required fields now empty, supports
  both schedule_id and agent_id params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 22:31:11 +08:00

4391 lines
169 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
内置工具实现
"""
from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path
import httpx
import json
import os
import logging
import re
import math
from datetime import datetime, timedelta
import platform
import sys
import asyncio
import subprocess
import shlex
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
logger = logging.getLogger(__name__)
def _local_file_workspace_root() -> Path:
"""file_read/file_write 允许操作的根目录(解析后路径)。"""
raw = (getattr(settings, "LOCAL_FILE_TOOLS_ROOT", None) or "").strip()
if raw:
return Path(raw).expanduser().resolve()
# backend/app/services/builtin_tools.py -> 上级 x4 = 仓库根(与历史上 join(services_dir, '../../..') 一致)
return Path(__file__).resolve().parent.parent.parent.parent
def _sanitize_tool_path_string(file_path: str) -> str:
"""
去掉 LLM/DSML/Markdown 泄漏到路径末尾的非法字符(如 123.md<|、引号),避免 WinError 123。
"""
if not isinstance(file_path, str):
return str(file_path)
s = file_path.replace("\ufeff", "").strip()
s = s.strip('"').strip("'")
# 行尾非法Windows 文件名不能含 <>:"|?*;模型常把半角/全角尖括号粘在扩展名后
tail_bad = '<>"|?*'
while s and s[-1] in tail_bad:
s = s[:-1].rstrip()
# 若仍含未配对尖括号片段在行尾(如 xxx.md<
s = re.sub(r'[<][|]?\s*$', "", s).rstrip()
return s
def _resolve_path_under_workspace(file_path: str) -> Tuple[Optional[Path], Optional[str]]:
"""将用户给定路径解析为绝对路径,且必须位于工作区内。"""
file_path = _sanitize_tool_path_string(file_path)
root = _local_file_workspace_root()
try:
p = Path(file_path).expanduser()
if not p.is_absolute():
p = (root / p).resolve()
else:
p = p.resolve()
except (OSError, ValueError) as e:
return None, str(e)
try:
p.relative_to(root)
except ValueError:
return None, f"不允许访问工作区外路径,允许根目录: {root}"
return p, None
_HTTP_RESPONSE_HEADER_ALLOWLIST = frozenset(
{
"content-type",
"content-length",
"content-encoding",
"date",
"last-modified",
"location",
"server",
"cache-control",
}
)
def _compact_http_response_headers(response: httpx.Response) -> Dict[str, str]:
"""减少 Set-Cookie 等大字段进入 LLM 上下文。"""
out: Dict[str, str] = {}
for key, value in response.headers.multi_items():
lk = key.lower()
if lk in ("set-cookie", "cookie"):
continue
if lk in _HTTP_RESPONSE_HEADER_ALLOWLIST:
out[key] = value if len(value) <= 2048 else value[:2048] + "..."
if not out:
n = 0
for key, value in response.headers.multi_items():
lk = key.lower()
if lk in ("set-cookie", "cookie"):
continue
if n >= 12:
break
out[key] = value if len(value) <= 512 else value[:512] + "..."
n += 1
return out
def _truncate_http_body_for_tool(body: Any, max_chars: int) -> Tuple[Any, bool, Optional[str]]:
"""
将 HTTP 正文限制在 max_chars 字符以内,避免门户首页等大 HTML 撑爆模型 context。
返回 (可能被截断后的 body, 是否截断, 说明文案)
"""
if max_chars <= 0:
max_chars = 32_000
note: Optional[str] = None
if isinstance(body, str):
if len(body) <= max_chars:
return body, False, None
note = (
f"正文已截断:原始约 {len(body)} 字符,仅保留前 {max_chars} 字符。"
"门户/频道首页通常过大,摘要请优先使用具体文章页 URL。"
)
return body[:max_chars], True, note
try:
serialized = json.dumps(body, ensure_ascii=False)
except (TypeError, ValueError):
serialized = str(body)
if len(serialized) <= max_chars:
return body, False, None
note = f"JSON 响应已截断:序列化长度约 {len(serialized)} 字符,仅保留前 {max_chars} 字符。"
return serialized[:max_chars], True, note
async def http_request_tool(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
body: Any = None,
max_body_chars: Optional[int] = None,
) -> str:
"""
HTTP请求工具
Args:
url: 请求URL
method: HTTP方法 (GET, POST, PUT, DELETE)
headers: 请求头
body: 请求体POST/PUT时使用
max_body_chars: 响应正文写入工具结果的最大字符数;默认读取配置 HTTP_REQUEST_MAX_BODY_CHARS
Returns:
JSON格式的响应结果
"""
try:
limit = max_body_chars
if limit is None:
limit = int(getattr(settings, "HTTP_REQUEST_MAX_BODY_CHARS", 32_000) or 32_000)
limit = max(4096, min(limit, 200_000))
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
method_upper = method.upper()
if method_upper == "GET":
response = await client.get(url, headers=headers)
elif method_upper == "POST":
response = await client.post(url, json=body, headers=headers)
elif method_upper == "PUT":
response = await client.put(url, json=body, headers=headers)
elif method_upper == "DELETE":
response = await client.delete(url, headers=headers)
else:
raise ValueError(f"不支持的HTTP方法: {method}")
# 尝试解析JSON响应
try:
response_body = response.json()
except Exception:
response_body = response.text
truncated_body, truncated, trunc_note = _truncate_http_body_for_tool(response_body, limit)
result: Dict[str, Any] = {
"status_code": response.status_code,
"headers": _compact_http_response_headers(response),
"body": truncated_body,
}
if truncated:
result["body_truncated"] = True
if trunc_note:
result["truncation_note"] = trunc_note
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.error(f"HTTP请求工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
_FILE_READ_TEXT_SUFFIXES = frozenset(
{
".txt",
".md",
".markdown",
".csv",
".tsv",
".json",
".jsonl",
".xml",
".html",
".htm",
".css",
".js",
".mjs",
".cjs",
".ts",
".tsx",
".jsx",
".vue",
".py",
".java",
".go",
".rs",
".sql",
".sh",
".bat",
".ps1",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".log",
".rst",
".tex",
".gitignore",
".env",
".properties",
}
)
_FILE_READ_IMAGE_SUFFIXES = frozenset(
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
)
def _truncate_utf8_text(s: str, max_bytes: int) -> str:
raw = s.encode("utf-8")
if len(raw) <= max_bytes:
return s
cut = raw[:max_bytes].decode("utf-8", errors="ignore")
return cut + "\n\n...[内容已按字节上限截断]"
def _read_pdf_text_sync(path: Path, max_bytes: int) -> str:
from pypdf import PdfReader
reader = PdfReader(str(path))
parts: List[str] = []
for page in reader.pages:
t = page.extract_text() or ""
if t.strip():
parts.append(t)
body = "\n\n".join(parts) if parts else ""
if not body.strip():
return "[PDF 未解析出文本,可能是扫描件或图片型 PDF可将页导出为图片后上传或安装 OCR 流程]"
return _truncate_utf8_text(body, max_bytes)
def _read_docx_text_sync(path: Path, max_bytes: int) -> str:
import docx
doc = docx.Document(str(path))
lines = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
body = "\n".join(lines)
return _truncate_utf8_text(body or "[docx 中未找到段落文本]", max_bytes)
def _read_xlsx_text_sync(path: Path, max_bytes: int) -> str:
from openpyxl import load_workbook
wb = load_workbook(filename=str(path), read_only=True, data_only=True)
try:
lines: List[str] = []
for ws in wb.worksheets:
lines.append(f"## {ws.title}")
for row in ws.iter_rows(values_only=True):
cells = ["" if c is None else str(c) for c in row]
if any(x.strip() for x in cells):
lines.append("\t".join(cells))
body = "\n".join(lines)
finally:
wb.close()
return _truncate_utf8_text(body or "[xlsx 中未读到单元格文本]", max_bytes)
def _tessdata_dir_for_ocr() -> Optional[Path]:
"""返回 tessdata 目录(内含 .traineddata传给 Tesseract --tessdata-dir。"""
raw = (getattr(settings, "TESSERACT_TESSDATA_DIR", None) or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
return p if p.is_dir() else None
root = _local_file_workspace_root()
loc = root / "tessdata"
if loc.is_dir() and any(loc.glob("*.traineddata")):
return loc.resolve()
return None
def _read_image_ocr_sync(path: Path, max_bytes: int) -> str:
from PIL import Image
import pytesseract
cmd = (getattr(settings, "TESSERACT_CMD", None) or "").strip()
if cmd:
pytesseract.pytesseract.tesseract_cmd = cmd
tess_cfg = ""
td = _tessdata_dir_for_ocr()
if td is not None:
tess_cfg = f"--tessdata-dir {shlex.quote(str(td))}"
img = Image.open(path)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
text = ""
last_err: Optional[Exception] = None
for lang in ("chi_sim+eng", "eng"):
try:
chunk = pytesseract.image_to_string(img, lang=lang, config=tess_cfg) or ""
if chunk.strip():
text = chunk
break
except Exception as e:
last_err = e
continue
if not text.strip() and last_err:
raise last_err
if not text.strip():
return "[图片中未识别到文字;可尝试更清晰、正向拍摄的作业照片]"
return _truncate_utf8_text(text, max_bytes)
async def file_read_tool(file_path: str) -> str:
"""
文件读取工具UTF-8 文本、PDF 文本、docx、xlsx 单元格文本、常见图片 OCR需本机 Tesseract
Args:
file_path: 文件路径(相对工作区根或工作区内绝对路径)
Returns:
JSONfile_path, content, size或 error
"""
try:
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
path, err = _resolve_path_under_workspace(file_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
if not path.is_file():
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
fsize = path.stat().st_size
if fsize > max_bytes:
return json.dumps(
{"error": f"文件过大({fsize} 字节),上限 {max_bytes}"},
ensure_ascii=False,
)
suffix = path.suffix.lower()
if suffix == ".doc":
return json.dumps(
{
"error": "暂不支持旧版 .doc请在 Word 中另存为 .docx 后上传",
"file_path": str(path),
},
ensure_ascii=False,
)
content: str
extract_mode = "text"
if suffix in _FILE_READ_IMAGE_SUFFIXES:
extract_mode = "image_ocr"
try:
content = await asyncio.to_thread(_read_image_ocr_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{
"error": f"图片识别依赖未安装: {e}。请在后端环境执行 pip install Pillow pytesseract",
"file_path": str(path),
},
ensure_ascii=False,
)
except Exception as e:
err_low = str(e).lower()
if "tesseract" in err_low or "tesseractnotfound" in type(e).__name__.lower():
return json.dumps(
{
"error": (
"未找到 Tesseract OCR。请安装 TesseractWindows 可装官方安装包),"
"并在 .env 中配置 TESSERACT_CMD 指向 tesseract.exe或将其加入 PATH"
"中文识别需额外下载 chi_sim 语言包。"
),
"file_path": str(path),
},
ensure_ascii=False,
)
logger.warning("图片 OCR 失败: %s", e)
return json.dumps({"error": f"图片识别失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".pdf":
extract_mode = "pdf"
try:
content = await asyncio.to_thread(_read_pdf_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"PDF 解析依赖未安装: {e}。请 pip install pypdf", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("PDF 解析失败: %s", e)
return json.dumps({"error": f"PDF 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".docx":
extract_mode = "docx"
try:
content = await asyncio.to_thread(_read_docx_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"Word 解析依赖未安装: {e}。请 pip install python-docx", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("docx 解析失败: %s", e)
return json.dumps({"error": f"docx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".xlsx":
extract_mode = "xlsx"
try:
content = await asyncio.to_thread(_read_xlsx_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"Excel 解析依赖未安装: {e}。请 pip install openpyxl", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("xlsx 解析失败: %s", e)
return json.dumps({"error": f"xlsx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix in _FILE_READ_TEXT_SUFFIXES or suffix == "":
extract_mode = "text"
content = path.read_text(encoding="utf-8", errors="replace")
content = _truncate_utf8_text(content, max_bytes)
else:
# 未知扩展名:仍尝试按 UTF-8 文本读(兼容无后缀文本)
extract_mode = "text"
content = path.read_text(encoding="utf-8", errors="replace")
content = _truncate_utf8_text(content, max_bytes)
out = {
"file_path": str(path),
"content": content,
"size": len(content.encode("utf-8")),
"extract_mode": extract_mode,
}
logger.info("file_read %s mode=%s content_len=%s", path, extract_mode, out["size"])
return json.dumps(out, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"文件读取工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
async def file_write_tool(file_path: str, content: str, mode: str = "w") -> str:
"""
文件写入工具
Args:
file_path: 文件路径
content: 要写入的内容
mode: 写入模式w=覆盖a=追加)
Returns:
写入结果
"""
try:
max_bytes = int(getattr(settings, "LOCAL_FILE_WRITE_MAX_BYTES", 2_097_152) or 2_097_152)
raw = content if isinstance(content, str) else str(content)
enc_len = len(raw.encode("utf-8"))
if enc_len > max_bytes:
return json.dumps(
{"error": f"写入内容过大({enc_len} 字节 UTF-8上限 {max_bytes}"},
ensure_ascii=False,
)
path, err = _resolve_path_under_workspace(file_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
path.parent.mkdir(parents=True, exist_ok=True)
write_mode = "a" if mode == "a" else "w"
if write_mode == "a":
with path.open("a", encoding="utf-8") as f:
f.write(raw)
else:
path.write_text(raw, encoding="utf-8")
return json.dumps(
{
"success": True,
"file_path": str(path),
"mode": write_mode,
"content_length": enc_len,
},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"文件写入工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
async def text_analyze_tool(text: str, operation: str = "count") -> str:
"""
文本分析工具
Args:
text: 要分析的文本
operation: 操作类型count=统计, keywords=提取关键词, summary=摘要)
Returns:
分析结果
"""
try:
if operation == "count":
# 统计字数、字符数、行数等
char_count = len(text)
char_count_no_spaces = len(text.replace(" ", ""))
word_count = len(text.split())
line_count = len(text.splitlines())
paragraph_count = len([p for p in text.split("\n\n") if p.strip()])
return json.dumps({
"char_count": char_count,
"char_count_no_spaces": char_count_no_spaces,
"word_count": word_count,
"line_count": line_count,
"paragraph_count": paragraph_count
}, ensure_ascii=False)
elif operation == "keywords":
# 简单的关键词提取(基于词频)
words = re.findall(r'\b\w+\b', text.lower())
word_freq = {}
for word in words:
if len(word) > 2: # 忽略太短的词
word_freq[word] = word_freq.get(word, 0) + 1
# 取频率最高的10个词
top_keywords = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10]
return json.dumps({
"keywords": [{"word": k, "frequency": v} for k, v in top_keywords]
}, ensure_ascii=False)
elif operation == "summary":
# 简单的摘要取前3句
sentences = re.split(r'[.!?。!?]\s+', text)
summary = ". ".join(sentences[:3]) + "."
return json.dumps({
"summary": summary,
"original_length": len(text),
"summary_length": len(summary)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"文本分析工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def datetime_tool(operation: str = "now", format: str = "%Y-%m-%d %H:%M:%S",
timezone: Optional[str] = None) -> str:
"""
日期时间工具
Args:
operation: 操作类型now=当前时间, format=格式化, parse=解析)
format: 时间格式
timezone: 时区(暂未实现)
Returns:
日期时间结果
"""
try:
if operation == "now":
now = datetime.now()
return json.dumps({
"datetime": now.strftime(format),
"timestamp": now.timestamp(),
"iso_format": now.isoformat()
}, ensure_ascii=False)
elif operation == "format":
now = datetime.now()
return json.dumps({
"formatted": now.strftime(format)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"日期时间工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def math_calculate_tool(expression: str) -> str:
"""
数学计算工具(安全限制:只允许基本数学运算)
Args:
expression: 数学表达式(如 "2+2", "sqrt(16)", "sin(0)"
Returns:
计算结果
"""
try:
# 安全检查:只允许数字、基本运算符和安全的数学函数
allowed_chars = set("0123456789+-*/.() ")
allowed_functions = ['sqrt', 'sin', 'cos', 'tan', 'log', 'exp', 'abs', 'pow']
# 检查表达式是否安全
if not all(c in allowed_chars or any(f in expression for f in allowed_functions) for c in expression):
return json.dumps({
"error": "表达式包含不安全的字符"
}, ensure_ascii=False)
# 构建安全的数学环境
safe_dict = {
"__builtins__": {},
"abs": abs,
"sqrt": math.sqrt,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"log": math.log,
"exp": math.exp,
"pow": pow,
"pi": math.pi,
"e": math.e
}
result = eval(expression, safe_dict)
return json.dumps({
"expression": expression,
"result": result,
"result_type": type(result).__name__
}, ensure_ascii=False)
except Exception as e:
logger.error(f"数学计算工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def system_info_tool() -> str:
"""
系统信息工具
Returns:
系统信息
"""
try:
info = {
"platform": platform.system(),
"platform_version": platform.version(),
"architecture": platform.machine(),
"processor": platform.processor(),
"python_version": sys.version,
"python_version_info": {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
},
# file_read/file_write 使用的允许根目录(与 LOCAL_FILE_TOOLS_ROOT 一致)
"local_file_workspace_root": str(_local_file_workspace_root()),
}
return json.dumps(info, ensure_ascii=False)
except Exception as e:
logger.error(f"系统信息工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def json_process_tool(json_string: str, operation: str = "parse") -> str:
"""
JSON处理工具
Args:
json_string: JSON字符串
operation: 操作类型parse=解析, stringify=序列化, validate=验证)
Returns:
处理结果
"""
try:
if operation == "parse":
data = json.loads(json_string)
return json.dumps({
"parsed": data,
"type": type(data).__name__
}, ensure_ascii=False)
elif operation == "stringify":
# 如果输入已经是字符串,尝试解析后再序列化
try:
data = json.loads(json_string)
except:
data = json_string
return json.dumps({
"stringified": json.dumps(data, ensure_ascii=False, indent=2)
}, ensure_ascii=False)
elif operation == "validate":
try:
json.loads(json_string)
return json.dumps({
"valid": True
}, ensure_ascii=False)
except json.JSONDecodeError as e:
return json.dumps({
"valid": False,
"error": str(e)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"JSON处理工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
def _validate_sql_query(sql: str) -> Tuple[bool, Optional[str]]:
"""
验证SQL查询的安全性
Args:
sql: SQL查询语句
Returns:
(是否安全, 错误信息)
"""
# 去除注释和多余空白
sql_clean = re.sub(r'--.*?\n', '', sql)
sql_clean = re.sub(r'/\*.*?\*/', '', sql_clean, flags=re.DOTALL)
sql_clean = ' '.join(sql_clean.split())
# 转换为小写以便检查
sql_lower = sql_clean.lower().strip()
# 只允许SELECT查询
if not sql_lower.startswith('select'):
return False, "只允许SELECT查询不允许INSERT、UPDATE、DELETE、DROP等操作"
# 检查危险关键字
dangerous_keywords = [
'insert', 'update', 'delete', 'drop', 'truncate', 'alter',
'create', 'grant', 'revoke', 'exec', 'execute', 'call',
'commit', 'rollback', 'savepoint'
]
for keyword in dangerous_keywords:
# 使用单词边界匹配,避免误判(如"select"中包含"select"
pattern = r'\b' + keyword + r'\b'
if re.search(pattern, sql_lower):
return False, f"检测到危险关键字: {keyword.upper()},查询被拒绝"
# 检查是否有多个SQL语句防止SQL注入
if ';' in sql_clean and sql_clean.count(';') > 1:
return False, "不允许执行多个SQL语句"
return True, None
async def _execute_default_db_query(sql: str, timeout: int = 30) -> List[Dict[str, Any]]:
"""
执行默认数据库查询
Args:
sql: SQL查询语句
timeout: 查询超时时间(秒)
Returns:
查询结果列表
"""
from app.core.database import engine
try:
# 使用asyncio实现超时控制
loop = asyncio.get_event_loop()
def _execute():
with engine.connect() as connection:
result = connection.execute(text(sql))
# 获取列名
columns = result.keys()
# 获取所有行
rows = result.fetchall()
# 转换为字典列表
return [dict(zip(columns, row)) for row in rows]
# 执行查询,带超时控制
result = await asyncio.wait_for(
asyncio.to_thread(_execute),
timeout=timeout
)
return result
except asyncio.TimeoutError:
raise Exception(f"查询超时(超过{timeout}秒)")
except SQLAlchemyError as e:
raise Exception(f"数据库查询失败: {str(e)}")
except Exception as e:
raise Exception(f"执行查询时发生错误: {str(e)}")
async def _execute_data_source_query(data_source_id: str, sql: str, timeout: int = 30) -> List[Dict[str, Any]]:
"""
通过数据源ID执行查询
Args:
data_source_id: 数据源ID
sql: SQL查询语句
timeout: 查询超时时间(秒)
Returns:
查询结果列表
"""
from app.core.database import SessionLocal
from app.models.data_source import DataSource
from app.services.data_source_connector import create_connector
db = SessionLocal()
try:
# 获取数据源配置
data_source = db.query(DataSource).filter(DataSource.id == data_source_id).first()
if not data_source:
raise Exception(f"数据源不存在: {data_source_id}")
if data_source.status != 'active':
raise Exception(f"数据源状态异常: {data_source.status}")
# 创建连接器
connector = create_connector(data_source.type, data_source.config)
# 执行查询(带超时控制)
query_params = {'sql': sql}
# 使用asyncio实现超时控制
def _execute():
return connector.query(query_params)
result = await asyncio.wait_for(
asyncio.to_thread(_execute),
timeout=timeout
)
# 确保结果是列表格式
if not isinstance(result, list):
result = [result] if result else []
return result
except asyncio.TimeoutError:
raise Exception(f"查询超时(超过{timeout}秒)")
except Exception as e:
raise Exception(f"数据源查询失败: {str(e)}")
finally:
db.close()
async def database_query_tool(
query: str,
database: str = "default",
data_source_id: Optional[str] = None,
timeout: int = 30
) -> str:
"""
数据库查询工具
Args:
query: SQL查询语句只允许SELECT
database: 数据库名称(已废弃,保留用于兼容)
data_source_id: 数据源ID可选如果提供则使用指定数据源
timeout: 查询超时时间默认30秒
Returns:
JSON格式的查询结果
"""
try:
# 验证SQL安全性
is_safe, error_msg = _validate_sql_query(query)
if not is_safe:
return json.dumps({
"error": error_msg,
"query": query
}, ensure_ascii=False)
# 验证超时时间
if timeout <= 0 or timeout > 300:
return json.dumps({
"error": "超时时间必须在1-300秒之间",
"timeout": timeout
}, ensure_ascii=False)
# 执行查询
if data_source_id:
# 使用指定的数据源
result = await _execute_data_source_query(data_source_id, query, timeout)
else:
# 使用默认数据库
result = await _execute_default_db_query(query, timeout)
# 格式化结果
return json.dumps({
"success": True,
"row_count": len(result),
"data": result,
"query": query
}, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"数据库查询工具执行失败: {str(e)}")
return json.dumps({
"error": str(e),
"query": query
}, ensure_ascii=False)
async def adb_log_tool(
command: str = "logcat",
filter_tag: Optional[str] = None,
level: Optional[str] = None,
max_lines: int = 100,
timeout: int = 10
) -> str:
"""
ADB日志工具 - 获取Android设备日志
Args:
command: ADB命令类型logcat=获取日志, devices=列出设备, shell=执行shell命令
filter_tag: 日志标签过滤(如 "ActivityManager", "SystemServer"
level: 日志级别过滤V/D/I/W/E/F/S"E" 只显示错误)
max_lines: 最大返回行数默认100行避免输出过长
timeout: 命令执行超时时间默认10秒
Returns:
JSON格式的执行结果
"""
try:
# 验证超时时间
if timeout <= 0 or timeout > 60:
return json.dumps({
"error": "超时时间必须在1-60秒之间",
"timeout": timeout
}, ensure_ascii=False)
# 验证最大行数
if max_lines <= 0 or max_lines > 10000:
return json.dumps({
"error": "最大行数必须在1-10000之间",
"max_lines": max_lines
}, ensure_ascii=False)
# 构建adb命令
if command == "logcat":
# 获取日志
adb_cmd = ["adb", "logcat", "-d"] # -d 表示获取已缓存的日志
# 添加日志级别过滤
if level:
level = level.upper()
if level in ["V", "D", "I", "W", "E", "F", "S"]:
adb_cmd.extend([f"*:{level}"])
else:
return json.dumps({
"error": f"无效的日志级别: {level},支持: V/D/I/W/E/F/S"
}, ensure_ascii=False)
# 添加标签过滤
if filter_tag:
adb_cmd.append(filter_tag)
# 限制输出行数
adb_cmd.extend(["-t", str(max_lines)])
elif command == "devices":
# 列出连接的设备
adb_cmd = ["adb", "devices", "-l"]
elif command == "shell":
# 执行shell命令受限只允许安全命令
if filter_tag:
# filter_tag在这里作为shell命令
# 只允许安全的命令
safe_commands = ["getprop", "dumpsys", "pm", "am", "settings"]
cmd_parts = filter_tag.split()
if not cmd_parts or cmd_parts[0] not in safe_commands:
return json.dumps({
"error": f"不允许执行命令: {filter_tag},只允许: {', '.join(safe_commands)}"
}, ensure_ascii=False)
adb_cmd = ["adb", "shell"] + cmd_parts
else:
return json.dumps({
"error": "shell命令需要提供filter_tag参数作为命令"
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的ADB命令: {command},支持: logcat/devices/shell"
}, ensure_ascii=False)
# 执行adb命令
def _execute_adb():
try:
result = subprocess.run(
adb_cmd,
capture_output=True,
text=True,
timeout=timeout,
encoding='utf-8',
errors='replace' # 处理编码错误
)
return result
except subprocess.TimeoutExpired:
raise Exception(f"ADB命令执行超时超过{timeout}秒)")
except FileNotFoundError:
raise Exception("未找到adb命令请确保已安装Android SDK Platform Tools并配置PATH")
except Exception as e:
raise Exception(f"执行ADB命令失败: {str(e)}")
# 异步执行命令
result = await asyncio.wait_for(
asyncio.to_thread(_execute_adb),
timeout=timeout + 2 # 额外2秒缓冲
)
# 处理结果
output_lines = result.stdout.split('\n') if result.stdout else []
error_lines = result.stderr.split('\n') if result.stderr else []
# 如果输出太长,截断
if len(output_lines) > max_lines:
output_lines = output_lines[:max_lines]
output_lines.append(f"... (已截断,共 {len(result.stdout.split(chr(10)))} 行)")
return json.dumps({
"success": result.returncode == 0,
"command": " ".join(adb_cmd),
"return_code": result.returncode,
"output": "\n".join(output_lines),
"output_lines": len(output_lines),
"error": "\n".join(error_lines) if error_lines and any(error_lines) else None,
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False)
except asyncio.TimeoutError:
return json.dumps({
"error": f"ADB命令执行超时超过{timeout}秒)",
"command": " ".join(adb_cmd) if 'adb_cmd' in locals() else command
}, ensure_ascii=False)
except Exception as e:
logger.error(f"ADB日志工具执行失败: {str(e)}")
return json.dumps({
"error": str(e),
"command": " ".join(adb_cmd) if 'adb_cmd' in locals() else command
}, ensure_ascii=False)
# 工具定义OpenAI Function格式
HTTP_REQUEST_SCHEMA = {
"type": "function",
"function": {
"name": "http_request",
"description": "发送HTTP请求支持GET、POST、PUT、DELETE方法。可以用于调用API、获取网页内容等。",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "请求的URL地址"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"description": "HTTP请求方法",
"default": "GET"
},
"headers": {
"type": "object",
"description": "HTTP请求头可选",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"description": "请求体POST/PUT时使用可选"
},
"max_body_chars": {
"type": "integer",
"description": (
"响应正文写入结果的最大字符数(可选)。"
"门户首页等大 HTML 默认会按平台配置截断;摘要单篇文章可适当调大(如 80000仍可能受模型总上下文限制。"
),
},
},
"required": ["url", "method"]
}
}
}
FILE_READ_SCHEMA = {
"type": "function",
"function": {
"name": "file_read",
"description": (
"读取工作区内文件并尽量提取可读文本UTF-8 文本、.pdf 文字层、.docx 段落、.xlsx 单元格、"
"常见图片(.png/.jpg 等)用 OCR 提取文字(需服务器安装 Tesseract可选配置 TESSERACT_CMD"
"用户上传附件后消息中会给出相对路径,请用本工具读取该路径。路径须落在 LOCAL_FILE_TOOLS_ROOT 下。"
),
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要读取的文件路径(相对于项目根目录或绝对路径)"
}
},
"required": ["file_path"]
}
}
}
FILE_WRITE_SCHEMA = {
"type": "function",
"function": {
"name": "file_write",
"description": "写入本地文本文件UTF-8w 覆盖 / a 追加)。路径须落在工作区内;父目录不存在会自动创建。勿写入密码、密钥等大型敏感内容。",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要写入的文件路径(相对于项目根目录或绝对路径)"
},
"content": {
"type": "string",
"description": "要写入的内容"
},
"mode": {
"type": "string",
"enum": ["w", "a"],
"description": "写入模式w=覆盖写入a=追加写入",
"default": "w"
}
},
"required": ["file_path", "content"]
}
}
}
TEXT_ANALYZE_SCHEMA = {
"type": "function",
"function": {
"name": "text_analyze",
"description": "分析文本内容,支持统计字数、提取关键词、生成摘要等功能。",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "要分析的文本内容"
},
"operation": {
"type": "string",
"enum": ["count", "keywords", "summary"],
"description": "操作类型count=统计字数等信息keywords=提取关键词summary=生成摘要",
"default": "count"
}
},
"required": ["text"]
}
}
}
DATETIME_SCHEMA = {
"type": "function",
"function": {
"name": "datetime",
"description": "获取和处理日期时间信息,支持获取当前时间、格式化时间等。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["now", "format"],
"description": "操作类型now=获取当前时间format=格式化时间",
"default": "now"
},
"format": {
"type": "string",
"description": "时间格式字符串(如:%Y-%m-%d %H:%M:%S",
"default": "%Y-%m-%d %H:%M:%S"
}
}
}
}
}
MATH_CALCULATE_SCHEMA = {
"type": "function",
"function": {
"name": "math_calculate",
"description": "执行数学计算支持基本运算和常用数学函数如sqrt, sin, cos, log等",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式2+2, sqrt(16), sin(0), pow(2,3)"
}
},
"required": ["expression"]
}
}
}
SYSTEM_INFO_SCHEMA = {
"type": "function",
"function": {
"name": "system_info",
"description": "获取系统信息(含 local_file_workspace_rootfile_read/file_write 允许访问的根目录)。用户问「工作区路径」时应调用本工具并如实转述该字段。",
"parameters": {
"type": "object",
"properties": {}
}
}
}
JSON_PROCESS_SCHEMA = {
"type": "function",
"function": {
"name": "json_process",
"description": "处理JSON数据支持解析、序列化、验证等功能。",
"parameters": {
"type": "object",
"properties": {
"json_string": {
"type": "string",
"description": "JSON字符串"
},
"operation": {
"type": "string",
"enum": ["parse", "stringify", "validate"],
"description": "操作类型parse=解析JSONstringify=序列化为JSONvalidate=验证JSON格式",
"default": "parse"
}
},
"required": ["json_string"]
}
}
}
ADB_LOG_SCHEMA = {
"type": "function",
"function": {
"name": "adb_log",
"description": "执行ADB命令获取Android设备日志。支持logcat获取日志、devices列出设备、shell执行shell命令。可以过滤日志标签和级别。",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"enum": ["logcat", "devices", "shell"],
"description": "ADB命令类型logcat=获取日志devices=列出连接的设备shell=执行shell命令",
"default": "logcat"
},
"filter_tag": {
"type": "string",
"description": "日志标签过滤(如 'ActivityManager', 'SystemServer'或shell命令当command=shell时"
},
"level": {
"type": "string",
"enum": ["V", "D", "I", "W", "E", "F", "S"],
"description": "日志级别过滤V=Verbose, D=Debug, I=Info, W=Warning, E=Error, F=Fatal, S=Silent"
},
"max_lines": {
"type": "integer",
"description": "最大返回行数默认100行避免输出过长",
"default": 100
},
"timeout": {
"type": "integer",
"description": "命令执行超时时间默认10秒最大60秒",
"default": 10
}
}
}
}
}
DATABASE_QUERY_SCHEMA = {
"type": "function",
"function": {
"name": "database_query",
"description": "执行数据库查询只允许SELECT查询支持默认数据库和指定数据源。可以查询工作流、Agent、执行记录等系统数据或通过数据源ID查询外部数据库。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL查询语句只允许SELECT查询不允许INSERT、UPDATE、DELETE等操作"
},
"data_source_id": {
"type": "string",
"description": "数据源ID可选如果提供则使用指定的数据源否则使用默认数据库"
},
"timeout": {
"type": "integer",
"description": "查询超时时间默认30秒最大300秒",
"default": 30,
"minimum": 1,
"maximum": 300
}
},
"required": ["query"]
}
}
}
# ─── 加密/哈希/编码工具 ──────────────────────────────────
async def crypto_util_tool(
operation: str = "uuid",
data: Optional[str] = None,
algorithm: str = "sha256",
) -> str:
"""
加密/哈希/编码工具。
Args:
operation: 操作类型
- uuid: 生成 UUID
- base64_encode: Base64 编码
- base64_decode: Base64 解码
- hash: 计算哈希值
data: 输入数据base64/hash 操作需要)
algorithm: 哈希算法hash 操作时使用,支持 md5/sha1/sha256/sha512
Returns:
操作结果
"""
import hashlib
import base64
import uuid as _uuid
try:
if operation == "uuid":
u = str(_uuid.uuid4())
return json.dumps({"uuid": u, "type": "uuid4"}, ensure_ascii=False)
elif operation == "base64_encode":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
encoded = base64.b64encode(data.encode("utf-8")).decode("utf-8")
return json.dumps({"encoded": encoded}, ensure_ascii=False)
elif operation == "base64_decode":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
try:
decoded = base64.b64decode(data.encode("utf-8")).decode("utf-8")
return json.dumps({"decoded": decoded}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"Base64 解码失败: {e}"}, ensure_ascii=False)
elif operation == "hash":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
valid_algos = {"md5": hashlib.md5, "sha1": hashlib.sha1,
"sha256": hashlib.sha256, "sha512": hashlib.sha512}
if algorithm not in valid_algos:
return json.dumps(
{"error": f"不支持的算法: {algorithm},支持: {list(valid_algos.keys())}"},
ensure_ascii=False,
)
h = valid_algos[algorithm](data.encode("utf-8")).hexdigest()
return json.dumps({"algorithm": algorithm, "hash": h}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: uuid/base64_encode/base64_decode/hash"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"crypto_util 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"操作失败: {e}"}, ensure_ascii=False)
# ─── 随机生成工具 ─────────────────────────────────────────
async def random_generate_tool(
generate_type: str = "password",
length: int = 16,
count: int = 1,
min_val: float = 0,
max_val: float = 100,
) -> str:
"""
随机数据生成工具。
Args:
generate_type: 生成类型
- password: 安全密码(含大小写字母+数字+符号)
- string: 随机字符串(字母+数字)
- number: 随机整数
- float: 随机浮点数
length: 密码/字符串长度默认16
count: 生成数量默认1
min_val: 随机数最小值number/float 时使用)
max_val: 随机数最大值number/float 时使用)
Returns:
生成的随机数据
"""
import random
import string as _string
import secrets
try:
count = max(1, min(count, 50)) # 限制最多 50 个
if generate_type == "password":
length = max(8, min(length, 128))
chars = _string.ascii_letters + _string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?"
result = []
for _ in range(count):
pwd = "".join(secrets.choice(chars) for _ in range(length))
result.append(pwd)
return json.dumps({
"passwords": result,
"length": length,
"strength": "high" if length >= 16 else "medium" if length >= 12 else "basic",
}, ensure_ascii=False)
elif generate_type == "string":
length = max(1, min(length, 256))
chars = _string.ascii_letters + _string.digits
result = ["".join(secrets.choice(chars) for _ in range(length)) for _ in range(count)]
return json.dumps({"strings": result, "length": length}, ensure_ascii=False)
elif generate_type == "number":
min_val, max_val = int(min_val), int(max_val)
if min_val > max_val:
min_val, max_val = max_val, min_val
result = [secrets.randbelow(max_val - min_val + 1) + min_val for _ in range(count)]
return json.dumps({"numbers": result, "range": [min_val, max_val]}, ensure_ascii=False)
elif generate_type == "float":
if min_val > max_val:
min_val, max_val = max_val, min_val
result = [round(random.uniform(min_val, max_val), 6) for _ in range(count)]
return json.dumps({"floats": result, "range": [min_val, max_val]}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的类型: {generate_type},支持: password/string/number/float"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"random_generate 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"生成失败: {e}"}, ensure_ascii=False)
# ─── 邮件发送工具 ─────────────────────────────────────────
async def send_email_tool(
to: str,
subject: str,
body: str,
smtp_host: str = "",
smtp_port: int = 587,
smtp_user: str = "",
smtp_password: str = "",
from_name: str = "",
body_type: str = "plain",
) -> str:
"""
发送邮件工具。
Args:
to: 收件人邮箱地址(多个用逗号分隔)
subject: 邮件主题
body: 邮件正文
smtp_host: SMTP 服务器地址(如 smtp.qq.com留空从环境变量读取
smtp_port: SMTP 端口(默认 587
smtp_user: SMTP 用户名/发件人邮箱,留空从环境变量读取
smtp_password: SMTP 密码/授权码,留空从环境变量读取
from_name: 发件人显示名称(可选)
body_type: 正文类型 plain=纯文本, html=HTML
Returns:
发送结果
"""
try:
# 从环境变量读取 SMTP 配置(如果未传参)
import os
host = smtp_host or os.getenv("SMTP_HOST", "")
user = smtp_user or os.getenv("SMTP_USER", "")
password = smtp_password or os.getenv("SMTP_PASSWORD", "")
if not host or not user or not password:
return json.dumps({
"error": "SMTP 配置不完整。请在 .env 中设置 SMTP_HOST/SMTP_USER/SMTP_PASSWORD或在调用时传入 smtp_host/smtp_user/smtp_password 参数",
}, ensure_ascii=False)
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
recipients = [r.strip() for r in to.split(",") if r.strip()]
if not recipients:
return json.dumps({"error": "收件人地址不能为空"}, ensure_ascii=False)
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{user}>" if from_name else user
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText(body, body_type if body_type == "html" else "plain", "utf-8"))
await aiosmtplib.send(
msg,
hostname=host,
port=smtp_port,
username=user,
password=password,
start_tls=True,
)
return json.dumps({
"success": True,
"message": f"邮件已发送到 {len(recipients)} 个收件人",
"recipients": recipients,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"send_email 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"邮件发送失败: {e}"}, ensure_ascii=False)
# ─── URL 解析工具 ─────────────────────────────────────────
async def url_parse_tool(
url: str,
operation: str = "parse",
key: Optional[str] = None,
params: Optional[str] = None,
) -> str:
"""
URL 解析和构建工具。
Args:
url: 要处理的 URL
operation: 操作类型
- parse: 解析 URL 为各部分(协议/主机/端口/路径/参数/片段)
- get_param: 获取指定查询参数的值(需传 key
- all_params: 获取所有查询参数
- build: 构建 URLurl=基础URL, params=要追加的参数 JSON
key: 查询参数名operation=get_param 时使用)
params: 参数 JSON 字符串operation=build 时使用,如 {"page": "1", "size": "10"}
Returns:
处理结果
"""
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
try:
if operation == "parse":
parsed = urlparse(url)
return json.dumps({
"scheme": parsed.scheme,
"hostname": parsed.hostname,
"port": parsed.port,
"path": parsed.path,
"params": parsed.params,
"query": parsed.query,
"fragment": parsed.fragment,
}, ensure_ascii=False)
elif operation == "get_param":
if not key:
return json.dumps({"error": "需要提供 key 参数"}, ensure_ascii=False)
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
val = query_params.get(key, [None])[0]
return json.dumps({"key": key, "value": val}, ensure_ascii=False)
elif operation == "all_params":
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
# parse_qs 返回 {key: [value1, value2]},简化为单值
flat = {k: v[0] if len(v) == 1 else v for k, v in query_params.items()}
return json.dumps({"params": flat}, ensure_ascii=False)
elif operation == "build":
parsed = urlparse(url)
existing = parse_qs(parsed.query, keep_blank_values=True)
if params:
try:
extra = json.loads(params)
if isinstance(extra, dict):
existing.update(extra)
except json.JSONDecodeError as e:
return json.dumps({"error": f"params JSON 解析失败: {e}"}, ensure_ascii=False)
new_query = urlencode(existing, doseq=True)
new_parsed = parsed._replace(query=new_query)
new_url = urlunparse(new_parsed)
return json.dumps({"url": new_url}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: parse/get_param/all_params/build"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"url_parse 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"URL 处理失败: {e}"}, ensure_ascii=False)
# ─── 正则表达式工具 ─────────────────────────────────────
async def regex_test_tool(
pattern: str,
test_string: str,
operation: str = "match",
flags: str = "",
replacement: str = "",
) -> str:
"""
正则表达式测试工具。
Args:
pattern: 正则表达式模式
test_string: 要测试的字符串
operation: 操作类型
- match: 查找第一个匹配
- findall: 查找所有匹配
- replace: 替换匹配内容(需传 replacement
- split: 按正则分割字符串
- validate: 验证完整字符串是否匹配
flags: 标志可选组合i=忽略大小写, m=多行, s=点匹配换行)
replacement: 替换文本operation=replace 时使用)
Returns:
处理结果
"""
import re as _re
try:
# 解析 flags
flag_val = 0
for c in flags.lower():
if c == "i":
flag_val |= _re.IGNORECASE
elif c == "m":
flag_val |= _re.MULTILINE
elif c == "s":
flag_val |= _re.DOTALL
if operation == "match":
m = _re.search(pattern, test_string, flag_val)
if m:
info = {"match": m.group(0), "start": m.start(), "end": m.end()}
if m.groups():
info["groups"] = list(m.groups())
if m.groupdict():
info["named_groups"] = {k: v for k, v in m.groupdict().items()}
return json.dumps(info, ensure_ascii=False)
return json.dumps({"match": None, "message": "无匹配"}, ensure_ascii=False)
elif operation == "findall":
matches = _re.findall(pattern, test_string, flag_val)
return json.dumps({"count": len(matches), "matches": matches[:100]}, ensure_ascii=False)
elif operation == "replace":
result = _re.sub(pattern, replacement, test_string, flags=flag_val)
return json.dumps({"result": result}, ensure_ascii=False)
elif operation == "split":
parts = _re.split(pattern, test_string, flags=flag_val)
return json.dumps({"parts": parts, "count": len(parts)}, ensure_ascii=False)
elif operation == "validate":
is_match = bool(_re.fullmatch(pattern, test_string, flag_val))
return json.dumps({"valid": is_match, "pattern": pattern}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: match/findall/replace/split/validate"},
ensure_ascii=False,
)
except _re.error as e:
return json.dumps({"error": f"正则表达式语法错误: {e}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"regex_test 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"操作失败: {e}"}, ensure_ascii=False)
# ─── 定时任务工具 ─────────────────────────────────────────
async def schedule_create_tool(
agent_id: str,
name: str,
cron_expression: str,
input_message: str,
webhook_url: Optional[str] = None,
) -> str:
"""
为 Agent 创建定时任务。
Args:
agent_id: Agent ID
name: 任务名称,如"每日早报"
cron_expression: 标准 5 位 cron 表达式,如 0 9 * * * 表示每天9点
input_message: 定时执行时发送给 Agent 的消息
webhook_url: 可选,飞书机器人 Webhook URL执行完成后推送通知
Returns:
执行结果
"""
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.models.agent_schedule import AgentSchedule
from app.services.agent_schedule_service import compute_next_run
db = SessionLocal()
try:
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
return json.dumps({"error": f"Agent 不存在: {agent_id}"}, ensure_ascii=False)
# 尝试计算下次执行时间
try:
next_run = compute_next_run(cron_expression)
except (ValueError, KeyError) as e:
return json.dumps({"error": f"cron 表达式无效: {e}(标准 5 位格式,如 0 9 * * *"}, ensure_ascii=False)
# 获取 user_id优先代理的 owner否则 fallback 到第一个用户
user_id = agent.user_id
if not user_id:
from app.models.user import User
first_user = db.query(User).first()
if not first_user:
return json.dumps({"error": "数据库无用户,无法创建定时任务"}, ensure_ascii=False)
user_id = first_user.id
schedule = AgentSchedule(
agent_id=agent_id,
name=name,
cron_expression=cron_expression,
input_message=input_message,
webhook_url=webhook_url,
enabled=True,
next_run_at=next_run,
user_id=user_id,
)
db.add(schedule)
db.commit()
db.refresh(schedule)
return json.dumps({
"success": True,
"schedule_id": schedule.id,
"name": schedule.name,
"cron": schedule.cron_expression,
"next_run_at": next_run.isoformat(),
"message": f"定时任务「{name}」已创建,将于 {next_run.isoformat()} 首次执行",
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"schedule_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"创建定时任务失败: {e}"}, ensure_ascii=False)
async def schedule_list_tool(agent_id: str) -> str:
"""
列出 Agent 的所有定时任务。
Args:
agent_id: Agent ID
Returns:
定时任务列表
"""
try:
from app.core.database import SessionLocal
from app.models.agent_schedule import AgentSchedule
db = SessionLocal()
try:
schedules = (
db.query(AgentSchedule)
.filter(
AgentSchedule.agent_id == agent_id,
)
.order_by(AgentSchedule.created_at.desc())
.all()
)
items = []
for s in schedules:
items.append({
"id": s.id,
"name": s.name,
"cron": s.cron_expression,
"enabled": s.enabled,
"next_run": s.next_run_at.isoformat() if s.next_run_at else None,
"last_run": s.last_run_at.isoformat() if s.last_run_at else None,
"last_status": s.last_run_status,
})
return json.dumps({
"count": len(items),
"schedules": items,
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"schedule_list 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"查询定时任务失败: {e}"}, ensure_ascii=False)
async def schedule_delete_tool(
schedule_id: str = "",
agent_id: str = "",
) -> str:
"""
删除指定的定时任务。
支持三种删除方式:
1. 按 schedule_id 精确删除
2. 按 agent_id 删除该 Agent 下所有定时任务(批量清理)
Args:
schedule_id: 定时任务 ID精确删除时使用
agent_id: Agent ID批量删除该 Agent 的所有任务时使用,或用于校验 schedule_id 所属权)
Returns:
删除结果
"""
try:
from app.core.database import SessionLocal
from app.models.agent_schedule import AgentSchedule
if not schedule_id and not agent_id:
return json.dumps({
"error": "missing_params",
"message": "请提供 schedule_id删除单个或 agent_id批量删除该 Agent 的所有定时任务)",
}, ensure_ascii=False)
db = SessionLocal()
try:
if schedule_id:
# 按 ID 精确删除
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
db.close()
return json.dumps({"error": f"定时任务不存在: {schedule_id}"}, ensure_ascii=False)
# 所有权校验:如果提供了 agent_id确保该 schedule 属于此 Agent
if agent_id and schedule.agent_id != agent_id:
db.close()
return json.dumps({
"error": "permission_denied",
"message": f"定时任务「{schedule.name}」不属于 Agent {agent_id}",
}, ensure_ascii=False)
name = schedule.name
db.delete(schedule)
db.commit()
db.close()
return json.dumps({
"success": True,
"message": f"定时任务「{name}」已删除",
}, ensure_ascii=False)
elif agent_id:
# 批量删除该 Agent 的所有定时任务
schedules = db.query(AgentSchedule).filter(AgentSchedule.agent_id == agent_id).all()
if not schedules:
db.close()
return json.dumps({
"success": True,
"message": f"Agent {agent_id} 没有定时任务需要删除",
"deleted_count": 0,
}, ensure_ascii=False)
names = [s.name for s in schedules]
count = len(schedules)
for s in schedules:
db.delete(s)
db.commit()
db.close()
return json.dumps({
"success": True,
"message": f"已删除 {count} 个定时任务:{', '.join(names)}",
"deleted_count": count,
}, ensure_ascii=False)
finally:
try:
db.close()
except Exception:
pass
except Exception as e:
logger.error(f"schedule_delete 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"删除定时任务失败: {e}"}, ensure_ascii=False)
CRYPTO_UTIL_SCHEMA = {
"type": "function",
"function": {
"name": "crypto_util",
"description": "加密/哈希/编码工具:生成 UUID、Base64 编解码、计算 MD5/SHA256 等哈希值。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["uuid", "base64_encode", "base64_decode", "hash"],
"description": "操作uuid=生成唯一ID, base64_encode=编码, base64_decode=解码, hash=计算哈希",
"default": "uuid",
},
"data": {
"type": "string",
"description": "输入数据base64编解码或哈希时必填",
},
"algorithm": {
"type": "string",
"enum": ["md5", "sha1", "sha256", "sha512"],
"description": "哈希算法hash 操作时使用,默认 sha256",
"default": "sha256",
},
},
},
},
}
RANDOM_GENERATE_SCHEMA = {
"type": "function",
"function": {
"name": "random_generate",
"description": "安全随机数据生成:密码、随机字符串、随机整数、随机浮点数。使用 secrets 模块保证安全性。",
"parameters": {
"type": "object",
"properties": {
"generate_type": {
"type": "string",
"enum": ["password", "string", "number", "float"],
"description": "类型password=安全密码, string=随机字符串, number=随机整数, float=随机浮点数",
"default": "password",
},
"length": {
"type": "integer",
"description": "密码/字符串长度password 默认16最少8string 默认16",
"default": 16,
"minimum": 1,
"maximum": 256,
},
"count": {
"type": "integer",
"description": "生成数量默认1最多50",
"default": 1,
"minimum": 1,
"maximum": 50,
},
"min_val": {
"type": "number",
"description": "最小值number/float 类型使用)",
"default": 0,
},
"max_val": {
"type": "number",
"description": "最大值number/float 类型使用)",
"default": 100,
},
},
},
},
}
SEND_EMAIL_SCHEMA = {
"type": "function",
"function": {
"name": "send_email",
"description": "发送邮件。SMTP 配置可从环境变量读取SMTP_HOST/SMTP_USER/SMTP_PASSWORD也可在调用时直接传入。",
"parameters": {
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "收件人邮箱地址,多个用逗号分隔,如 a@x.com,b@x.com",
},
"subject": {
"type": "string",
"description": "邮件主题",
},
"body": {
"type": "string",
"description": "邮件正文内容",
},
"smtp_host": {
"type": "string",
"description": "SMTP 服务器地址(如 smtp.qq.com留空从环境变量 SMTP_HOST 读取",
},
"smtp_port": {
"type": "integer",
"description": "SMTP 端口,默认 587",
"default": 587,
},
"smtp_user": {
"type": "string",
"description": "发件人邮箱 / SMTP 用户名,留空从环境变量 SMTP_USER 读取",
},
"smtp_password": {
"type": "string",
"description": "SMTP 授权码/密码,留空从环境变量 SMTP_PASSWORD 读取",
},
"from_name": {
"type": "string",
"description": "发件人显示名称(可选)",
},
"body_type": {
"type": "string",
"enum": ["plain", "html"],
"description": "正文类型,默认 plain",
"default": "plain",
},
},
"required": ["to", "subject", "body"],
},
},
}
URL_PARSE_SCHEMA = {
"type": "function",
"function": {
"name": "url_parse",
"description": "URL 解析与构建工具:解析 URL 各部分、提取查询参数、构建带参数的 URL。",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要处理的 URL",
},
"operation": {
"type": "string",
"enum": ["parse", "get_param", "all_params", "build"],
"description": "操作parse=解析结构, get_param=取指定参数, all_params=所有参数, build=构建URL追加参数",
},
"key": {
"type": "string",
"description": "查询参数名get_param 操作时使用)",
},
"params": {
"type": "string",
"description": "参数 JSONbuild 操作时使用),如 {\"page\":\"1\"}",
},
},
"required": ["url"],
},
},
}
REGEX_TEST_SCHEMA = {
"type": "function",
"function": {
"name": "regex_test",
"description": "正则表达式测试工具:匹配查找、查找全部、替换、分割、验证等操作。",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "正则表达式模式,如 \\d{4}-\\d{2}-\\d{2}",
},
"test_string": {
"type": "string",
"description": "要测试的目标字符串",
},
"operation": {
"type": "string",
"enum": ["match", "findall", "replace", "split", "validate"],
"description": "操作match=首个匹配, findall=全部匹配, replace=替换, split=分割, validate=验证完整匹配",
"default": "match",
},
"flags": {
"type": "string",
"description": "标志组合i=忽略大小写, m=多行, s=点匹配换行(如 is 表示忽略大小写+点匹配换行)",
},
"replacement": {
"type": "string",
"description": "替换文本replace 操作时使用)",
},
},
"required": ["pattern", "test_string"],
},
},
}
SCHEDULE_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_create",
"description": "为 Agent 创建定时任务,按 cron 表达式周期自动执行。可用于设置每日推送、定时检查等。",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Agent ID即当前对话所属的 Agent",
},
"name": {
"type": "string",
"description": "任务名称,如「每日早报推送」「每小时检查」",
},
"cron_expression": {
"type": "string",
"description": "标准 5 位 cron 表达式,空格分隔。常见:每分钟=* * * * *,每小时=0 * * * *每天9点=0 9 * * *每天18点=0 18 * * *每周一9点=0 9 * * 1每月1号=0 9 1 * *",
},
"input_message": {
"type": "string",
"description": "每次定时触发时发送给 Agent 的消息内容,如「帮我生成今日工作汇总并推送」",
},
"webhook_url": {
"type": "string",
"description": "可选,飞书机器人 Webhook URL执行完成后推送到飞书群",
},
},
"required": ["agent_id", "name", "cron_expression", "input_message"],
},
},
}
SCHEDULE_LIST_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_list",
"description": "列出某个 Agent 的所有定时任务。",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Agent ID要查询的 Agent",
},
},
"required": ["agent_id"],
},
},
}
SCHEDULE_DELETE_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_delete",
"description": (
"删除定时任务。支持两种方式:"
"1) 提供 schedule_id 精确删除单个任务;"
"2) 提供 agent_id 批量删除该 Agent 的所有任务。"
"建议同时提供 agent_id 以校验所有权。"
),
"parameters": {
"type": "object",
"properties": {
"schedule_id": {
"type": "string",
"description": "定时任务 ID精确删除单个任务时使用可从 schedule_list 查询获得)",
},
"agent_id": {
"type": "string",
"description": "Agent ID用于校验 schedule_id 的所属权,或批量删除该 Agent 的所有任务)",
},
},
"required": [],
},
},
}
# ── agent_call ─────────────────────────────────────────────────
async def agent_call_tool(
agent_name: str,
query: str,
max_iterations: int = 10,
) -> str:
"""
调用另一个 Agent 处理任务并返回结果。
在数据库中按名称模糊匹配 Agent用其 workflow_config 中 agent/llm 节点的
配置执行一次 ReAct 推理,然后将结果返回给调用方(如全能助手)。
Args:
agent_name: 目标 Agent 名称(支持模糊匹配,匹配到多个时取最接近的一个)
query: 发给目标 Agent 的用户消息
max_iterations: 最大推理步数(默认 10
"""
import asyncio as _asyncio
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.agent_runtime.core import AgentRuntime
from app.agent_runtime.schemas import (
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
)
# 1. 查 DB模糊匹配 Agent
db = SessionLocal()
try:
candidates = (
db.query(Agent)
.filter(Agent.name.like(f"%{agent_name}%"))
.limit(5)
.all()
)
if not candidates:
return json.dumps(
{
"error": "agent_not_found",
"message": (
f"未找到匹配「{agent_name}」的 Agent。"
"请确认 Agent 名称是否正确,或在 Agent 管理中先创建目标 Agent。"
),
},
ensure_ascii=False,
)
# 精确匹配优先,否则取第一个
target = next(
(a for a in candidates if a.name == agent_name),
candidates[0],
)
finally:
db.close()
# 2. 从 workflow_config 提取 agent/llm 节点配置
wf = target.workflow_config or {}
nodes = wf.get("nodes", [])
agent_node = next(
(n for n in nodes if n.get("type") in ("agent", "llm")),
None,
)
if not agent_node:
return json.dumps(
{
"error": "no_agent_node",
"message": f"Agent「{target.name}」的工作流中未找到 Agent/LLM 节点,无法执行",
},
ensure_ascii=False,
)
nd = agent_node.get("data", {}) or {}
system_prompt = nd.get("system_prompt") or nd.get("prompt") or (
"你是一个有用的AI助手。"
)
model = nd.get("model", "deepseek-v4-flash")
provider = nd.get("provider", "deepseek")
temperature = float(nd.get("temperature", 0.7))
node_max_iter = int(nd.get("max_iterations") or 0)
if node_max_iter > 0:
max_iterations = min(max_iterations, node_max_iter)
# 3. 构建配置并执行
config = AgentConfig(
name=target.name or "sub_agent",
system_prompt=system_prompt,
llm=AgentLLMConfig(
provider=provider,
model=model,
temperature=temperature,
max_iterations=max_iterations,
),
tools=AgentToolConfig(
include_tools=nd.get("tools") or [],
exclude_tools=nd.get("exclude_tools") or [],
),
memory={
"enabled": nd.get("memory", True),
"persist_to_db": nd.get("memory", True),
},
)
runtime = AgentRuntime(config=config)
result = await runtime.run(query)
if result.success:
out = {
"agent": target.name,
"status": "success",
"iterations": result.iterations_used,
"tool_calls": result.tool_calls_made,
"reply": result.content,
}
else:
out = {
"agent": target.name,
"status": "error",
"error": result.error,
"reply": result.content or f"Agent 执行失败: {result.error}",
}
return json.dumps(out, ensure_ascii=False)
except Exception as e:
logger.error(f"agent_call 工具执行失败: {e}", exc_info=True)
return json.dumps(
{
"error": "execution_failed",
"message": f"调用 Agent 时出错: {e}",
},
ensure_ascii=False,
)
AGENT_CALL_SCHEMA = {
"type": "function",
"function": {
"name": "agent_call",
"description": (
"调用另一个已注册的 Agent 来处理任务并返回结果。适合用来将子任务委托给"
"具备特定专长的 Agent如家庭医生助手、代码助手等。会话不会被接管"
"结果会以文本形式返回给调用方用于整合回复。"
),
"parameters": {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "目标 Agent 名称,支持模糊匹配(如「家庭医生」「代码助手」)",
},
"query": {
"type": "string",
"description": "发给目标 Agent 的查询内容,可以包含上下文信息",
},
"max_iterations": {
"type": "integer",
"description": "最大推理步数(默认 10控制 Agent 的思考深度",
"default": 10,
},
},
"required": ["agent_name", "query"],
},
},
}
# ── code_execute ─────────────────────────────────────────────
CODE_EXECUTE_TIMEOUT = 30
CODE_EXECUTE_MAX_OUTPUT = 8000
_CODE_SAFE_BUILTINS = {
"abs": abs, "all": all, "any": any, "ascii": ascii,
"bin": bin, "bool": bool, "bytearray": bytearray, "bytes": bytes,
"chr": chr, "complex": complex, "dict": dict, "divmod": divmod,
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
"frozenset": frozenset, "getattr": getattr, "hasattr": hasattr,
"hash": hash, "hex": hex, "id": id, "int": int, "isinstance": isinstance,
"issubclass": issubclass, "iter": iter, "len": len, "list": list,
"map": map, "max": max, "min": min, "next": next, "object": object,
"oct": oct, "ord": ord, "pow": pow, "print": print, "range": range,
"repr": repr, "reversed": reversed, "round": round, "set": set,
"slice": slice, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"type": type, "zip": zip,
}
async def code_execute_tool(
code: str,
language: str = "python",
timeout: int = CODE_EXECUTE_TIMEOUT,
) -> str:
"""沙箱执行 Python/JS 代码并返回 stdout/stderr。"""
import asyncio as _asyncio
import tempfile
lang = language.lower().strip()
if lang not in ("python", "py", "javascript", "js"):
return json.dumps({"error": f"不支持的语言: {language},仅支持 python / javascript"}, ensure_ascii=False)
if lang in ("javascript", "js"):
# JS 用 node -e 执行
try:
proc = await _asyncio.create_subprocess_exec(
"node", "-e", code,
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")[:CODE_EXECUTE_MAX_OUTPUT],
"stderr": stderr.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Node.js 未安装,无法执行 JavaScript"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": f"执行超时 ({timeout}s)"}, ensure_ascii=False)
# Python: 写入临时文件再 exec保证安全
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as f:
f.write(code)
tmp_path = f.name
try:
proc = await _asyncio.create_subprocess_exec(
sys.executable, tmp_path,
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")[:CODE_EXECUTE_MAX_OUTPUT],
"stderr": stderr.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"returncode": proc.returncode,
}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": f"执行超时 ({timeout}s)"}, ensure_ascii=False)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
CODE_EXECUTE_SCHEMA = {
"type": "function",
"function": {
"name": "code_execute",
"description": "在沙箱中执行 Python 或 JavaScript 代码,返回 stdout/stderr。适合编写脚本验证逻辑、数据处理、自动化任务。超时默认 30s。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "要执行的代码"},
"language": {"type": "string", "enum": ["python", "javascript"], "description": "语言(默认 python", "default": "python"},
"timeout": {"type": "integer", "description": "超时秒数(默认 30", "default": 30},
},
"required": ["code"],
},
},
}
# ── git_operation ────────────────────────────────────────────
_GIT_ALLOWED_COMMANDS = {
"log": ["git", "log", "--oneline", "--max-count=50"],
"diff": ["git", "diff"],
"diff_staged": ["git", "diff", "--staged"],
"status": ["git", "status", "--short"],
"branch": ["git", "branch", "-a"],
"blame": ["git", "blame", "--date=short"],
"show": ["git", "show", "--stat"],
"tag": ["git", "tag", "-l"],
"remote": ["git", "remote", "-v"],
"rev_parse": ["git", "rev-parse", "--abbrev-ref", "HEAD"],
}
async def git_operation_tool(operation: str, file_path: str = "", revision: str = "HEAD") -> str:
"""执行 Git 只读操作log/diff/status/blame/show/branch/tag/remote"""
import asyncio as _asyncio
op = operation.lower().strip()
if op not in _GIT_ALLOWED_COMMANDS:
return json.dumps({
"error": f"不支持的 git 操作: {operation}",
"allowed": sorted(_GIT_ALLOWED_COMMANDS.keys()),
}, ensure_ascii=False)
base_cmd = list(_GIT_ALLOWED_COMMANDS[op])
# 需要文件路径的操作
if op in ("diff", "blame", "log") and file_path:
if op == "blame":
base_cmd.append(file_path)
elif op == "diff":
base_cmd.extend(["--", file_path])
elif op == "log":
base_cmd.extend(["--", file_path])
if op in ("show",) and revision:
base_cmd[-1] = revision
try:
proc = await _asyncio.create_subprocess_exec(
*base_cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=15)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:8000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Git 未安装或不在 PATH 中"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "Git 操作超时"}, ensure_ascii=False)
GIT_OPERATION_SCHEMA = {
"type": "function",
"function": {
"name": "git_operation",
"description": "执行 Git 只读操作log提交历史、diff差异、status状态、blame谁改了哪行、branch分支列表、show提交详情、tag标签、remote远程仓库。所有操作均为只读不会修改仓库。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": sorted(_GIT_ALLOWED_COMMANDS.keys()),
"description": "Git 操作名称",
},
"file_path": {"type": "string", "description": "文件路径diff/blame/log 时可选)"},
"revision": {"type": "string", "description": "提交哈希或分支名show 时使用)", "default": "HEAD"},
},
"required": ["operation"],
},
},
}
# ── web_search ───────────────────────────────────────────────
async def web_search_tool(query: str, max_results: int = 8) -> str:
"""搜索网页(使用 DuckDuckGo HTML 搜索,无需 API Key"""
import asyncio as _asyncio
try:
url = f"https://html.duckduckgo.com/html/?q={httpx.URL(query).raw_path.decode() if hasattr(httpx.URL(query), 'raw_path') else query}"
# 简单 URL 编码
import urllib.parse
safe_q = urllib.parse.quote(query, safe="")
url = f"https://html.duckduckgo.com/html/?q={safe_q}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
if resp.status_code != 200:
return json.dumps({"error": f"搜索失败 HTTP {resp.status_code}"}, ensure_ascii=False)
html = resp.text
# 简单解析搜索结果
results = []
# 匹配每个结果块:标题、摘要、链接
import re as _re
# DuckDuckGo HTML 结果模式
blocks = _re.findall(
r'<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
html, _re.DOTALL | _re.IGNORECASE,
)
if not blocks:
# 备选模式
blocks = _re.findall(
r'<a[^>]*rel="nofollow"[^>]*class="result__url"[^>]*href="([^"]*)"[^>]*>.*?<a[^>]*class="result__a"[^>]*>(.*?)</a>.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
html, _re.DOTALL | _re.IGNORECASE,
)
for i, (link, title, snippet) in enumerate(blocks[:max_results]):
title_clean = _re.sub(r"<.*?>", "", title).strip()
snippet_clean = _re.sub(r"<.*?>", "", snippet).strip()
results.append({
"index": i + 1,
"title": title_clean,
"url": link,
"snippet": snippet_clean[:300],
})
if not results:
return json.dumps({"message": "未找到搜索结果,请尝试更具体的关键词", "results": []}, ensure_ascii=False)
return json.dumps({"results": results, "count": len(results)}, ensure_ascii=False)
except Exception as e:
logger.error(f"web_search 失败: {e}")
return json.dumps({"error": f"搜索失败: {e}"}, ensure_ascii=False)
WEB_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "web_search",
"description": "搜索互联网获取网页信息,返回标题、摘要和 URL 列表。适合查找最新文档、技术方案、新闻资讯。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"max_results": {"type": "integer", "description": "最大结果数(默认 8", "default": 8},
},
"required": ["query"],
},
},
}
# ── pdf_generate ─────────────────────────────────────────────
async def pdf_generate_tool(markdown: str, output_path: str = "", title: str = "") -> str:
"""将 Markdown 内容生成 PDF 文件。优先用 weasyprint否则生成 HTML。"""
import tempfile
import asyncio as _asyncio
full_path = output_path or os.path.join(
_local_file_workspace_root(), f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
)
if not os.path.isabs(full_path):
full_path = os.path.join(_local_file_workspace_root(), full_path)
# 简单的 Markdown → HTML 转换(不使用外部库)
html_title = title or "报告"
html_body = _simple_md_to_html(markdown)
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>{html_title}</title>
<style>
body {{ font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; line-height: 1.8; color: #333; }}
h1 {{ border-bottom: 2px solid #1890ff; padding-bottom: 8px; }}
h2 {{ border-bottom: 1px solid #e8e8e8; padding-bottom: 6px; }}
code {{ background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
pre {{ background: #f5f5f5; padding: 16px; border-radius: 8px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #1890ff; margin: 16px 0; padding: 8px 16px; background: #f0f5ff; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
th {{ background: #fafafa; }}
</style></head>
<body>{html_body}</body></html>"""
# 尝试 weasyprint
try:
from weasyprint import HTML
HTML(string=html_content).write_pdf(full_path)
return json.dumps({"file": full_path, "format": "pdf", "engine": "weasyprint"}, ensure_ascii=False)
except ImportError:
pass
# 降级:保存为 HTML
html_path = full_path.rsplit(".", 1)[0] + ".html"
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
return json.dumps({
"file": html_path,
"format": "html",
"engine": "builtin",
"note": "weasyprint 未安装,已生成 HTML 文件。安装 weasyprint 后可直接生成 PDF。",
}, ensure_ascii=False)
def _simple_md_to_html(md: str) -> str:
"""极简 Markdown→HTML支持标题/列表/代码块/粗体/斜体/链接/表格。"""
lines = md.split("\n")
out = []
in_code_block = False
in_table = False
table_rows = []
for line in lines:
# 代码块
if line.strip().startswith("```"):
if in_code_block:
out.append("</code></pre>")
in_code_block = False
else:
out.append('<pre><code>')
in_code_block = True
continue
if in_code_block:
out.append(line)
continue
# 表格
if "|" in line and line.strip().startswith("|"):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if not in_table:
in_table = True
# 判断是否分隔行
if all(set(c) <= {"-", ":", " "} for c in cells):
continue
out.append("<table>")
table_rows.append(cells)
continue
elif in_table:
# 结束表格
if table_rows:
out.append("<thead><tr>" + "".join(f"<th>{c}</th>" for c in table_rows[0]) + "</tr></thead>")
for row in table_rows[1:]:
out.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
out.append("</table>")
in_table = False
table_rows = []
# 标题
if line.startswith("# "):
out.append(f"<h1>{line[2:]}</h1>")
elif line.startswith("## "):
out.append(f"<h2>{line[3:]}</h2>")
elif line.startswith("### "):
out.append(f"<h3>{line[4:]}</h3>")
elif line.startswith("#### "):
out.append(f"<h4>{line[5:]}</h4>")
# 无序列表
elif line.strip().startswith("- "):
out.append(f"<li>{_inline_md(line.strip()[2:])}</li>")
elif line.strip().startswith("* "):
out.append(f"<li>{_inline_md(line.strip()[2:])}</li>")
# 引用
elif line.startswith("> "):
out.append(f"<blockquote>{_inline_md(line[2:])}</blockquote>")
# 分隔线
elif line.strip() in ("---", "***"):
out.append("<hr>")
# 空行
elif not line.strip():
out.append("<br>")
# 普通段落
else:
out.append(f"<p>{_inline_md(line)}</p>")
if in_table:
if table_rows:
out.append("<thead><tr>" + "".join(f"<th>{c}</th>" for c in table_rows[0]) + "</tr></thead>")
for row in table_rows[1:]:
out.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
out.append("</table>")
return "\n".join(out)
def _inline_md(text: str) -> str:
"""处理行内 Markdown粗体/斜体/代码/链接/图片。"""
import re as _re
text = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = _re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
text = _re.sub(r"`(.+?)`", r"<code>\1</code>", text)
text = _re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r'<img src="\2" alt="\1">', text)
text = _re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
return text
PDF_GENERATE_SCHEMA = {
"type": "function",
"function": {
"name": "pdf_generate",
"description": "将 Markdown 内容生成 PDF 报告(需 weasyprint或降级为 HTML 文件。适合生成项目文档、分析报告、周报等。",
"parameters": {
"type": "object",
"properties": {
"markdown": {"type": "string", "description": "Markdown 格式的报告内容"},
"output_path": {"type": "string", "description": "输出文件路径(可选,默认自动生成)"},
"title": {"type": "string", "description": "报告标题(可选)"},
},
"required": ["markdown"],
},
},
}
# ── project_scaffold ─────────────────────────────────────────
_PROJECT_TEMPLATES = {
"fastapi": {
"description": "FastAPI 后端项目模板",
"files": {
"main.py": "from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get('/')\ndef root():\n return {'message': 'Hello'}\n",
"requirements.txt": "fastapi\nuvicorn\n",
"Dockerfile": "FROM python:3.12-slim\nWORKDIR /app\nCOPY . .\nRUN pip install -r requirements.txt\nCMD [\"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n",
},
},
"vue": {
"description": "Vue 3 + Vite 前端项目",
"files": {
"src/App.vue": "<template>\n <div id=\"app\">\n <h1>{{ title }}</h1>\n </div>\n</template>\n\n<script setup>\nconst title = 'Vue App'\n</script>\n",
"src/main.js": "import { createApp } from 'vue'\nimport App from './App.vue'\n\ncreateApp(App).mount('#app')\n",
"index.html": "<!DOCTYPE html>\n<html>\n<head><title>Vue App</title></head>\n<body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n</html>\n",
"package.json": '{"name":"my-app","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"vue":"^3.4"},"devDependencies":{"vite":"^5","@vitejs/plugin-vue":"^5"}}\n',
"vite.config.js": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\nexport default defineConfig({\n plugins: [vue()],\n})\n",
},
},
"react": {
"description": "React + Vite 项目模板",
"files": {
"src/App.jsx": "export default function App() {\n return <h1>React App</h1>\n}\n",
"src/main.jsx": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\n\nReactDOM.createRoot(document.getElementById('root')).render(<App />)\n",
"index.html": "<!DOCTYPE html>\n<html>\n<head><title>React App</title></head>\n<body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.jsx\"></script>\n</body>\n</html>\n",
"package.json": '{"name":"my-app","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18","react-dom":"^18"},"devDependencies":{"vite":"^5","@vitejs/plugin-react":"^4"}}\n',
"vite.config.js": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\nexport default defineConfig({\n plugins: [react()],\n})\n",
},
},
"python_cli": {
"description": "Python CLI 项目模板",
"files": {
"main.py": "#!/usr/bin/env python3\n\"\"\"CLI 工具描述\"\"\"\nimport argparse\n\ndef main():\n parser = argparse.ArgumentParser()\n parser.add_argument('input', help='输入文件')\n args = parser.parse_args()\n print(f'Processing: {args.input}')\n\nif __name__ == '__main__':\n main()\n",
"requirements.txt": "",
},
},
"shell": {
"description": "Shell 脚本项目模板",
"files": {
"run.sh": "#!/bin/bash\nset -euo pipefail\n\necho \"Script started\"\n",
},
},
}
async def project_scaffold_tool(template: str, project_name: str, target_dir: str = "") -> str:
"""生成项目脚手架。"""
tmpl = template.lower().strip()
if tmpl not in _PROJECT_TEMPLATES:
return json.dumps({
"error": f"未知模板: {template}",
"available": sorted(_PROJECT_TEMPLATES.keys()),
"description": {k: v["description"] for k, v in _PROJECT_TEMPLATES.items()},
}, ensure_ascii=False)
root = _local_file_workspace_root()
base = os.path.join(target_dir, project_name) if target_dir else os.path.join(root, project_name)
base = os.path.abspath(base)
if os.path.exists(base):
return json.dumps({"error": f"目录已存在: {base}"}, ensure_ascii=False)
tpl = _PROJECT_TEMPLATES[tmpl]
created = []
for rel_path, content in tpl["files"].items():
full = os.path.join(base, rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
created.append(rel_path)
return json.dumps({
"project_dir": base,
"template": tmpl,
"description": tpl["description"],
"files_created": created,
"count": len(created),
}, ensure_ascii=False)
PROJECT_SCAFFOLD_SCHEMA = {
"type": "function",
"function": {
"name": "project_scaffold",
"description": "按模板生成项目目录结构。支持 fastapi、vue、react、python_cli、shell。适合快速搭项目骨架。",
"parameters": {
"type": "object",
"properties": {
"template": {
"type": "string",
"enum": sorted(_PROJECT_TEMPLATES.keys()),
"description": "项目模板名称",
},
"project_name": {"type": "string", "description": "项目名称(将作为目录名)"},
"target_dir": {"type": "string", "description": "目标父目录(可选,默认工作区根目录)"},
},
"required": ["template", "project_name"],
},
},
}
# ── task_plan ────────────────────────────────────────────────
_TASK_PLAN_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "task_plans")
async def task_plan_tool(action: str, plan_id: str = "", title: str = "", steps_json: str = "", step_index: int = -1, status: str = "") -> str:
"""任务规划:创建/查看/更新/完成/删除 任务计划。"""
os.makedirs(_TASK_PLAN_DIR, exist_ok=True)
action = action.lower().strip()
if action == "list":
plans = []
for fname in sorted(os.listdir(_TASK_PLAN_DIR)):
if fname.endswith(".json"):
fpath = os.path.join(_TASK_PLAN_DIR, fname)
try:
with open(fpath, "r", encoding="utf-8") as f:
p = json.load(f)
plans.append({
"id": p.get("id", fname.replace(".json", "")),
"title": p.get("title", ""),
"total_steps": len(p.get("steps", [])),
"completed": sum(1 for s in p.get("steps", []) if s.get("status") == "done"),
"created_at": p.get("created_at", ""),
})
except Exception:
pass
return json.dumps({"plans": plans, "count": len(plans)}, ensure_ascii=False)
if action == "create":
if not title or not steps_json:
return json.dumps({"error": "create 操作需要 title 和 steps_json 参数"}, ensure_ascii=False)
try:
steps = json.loads(steps_json)
except json.JSONDecodeError:
return json.dumps({"error": "steps_json 不是有效 JSON"}, ensure_ascii=False)
plan_id = plan_id or f"plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
plan = {
"id": plan_id,
"title": title,
"steps": [{"index": i, "title": s, "status": "pending", "note": ""} for i, s in enumerate(steps)],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
fpath = os.path.join(_TASK_PLAN_DIR, f"{plan_id}.json")
with open(fpath, "w", encoding="utf-8") as f:
json.dump(plan, f, ensure_ascii=False, indent=2)
return json.dumps({"plan": plan, "file": fpath}, ensure_ascii=False)
if not plan_id:
return json.dumps({"error": "需要 plan_id 参数"}, ensure_ascii=False)
fpath = os.path.join(_TASK_PLAN_DIR, f"{plan_id}.json")
if not os.path.exists(fpath):
return json.dumps({"error": f"任务计划不存在: {plan_id}"}, ensure_ascii=False)
with open(fpath, "r", encoding="utf-8") as f:
plan = json.load(f)
if action == "view":
return json.dumps(plan, ensure_ascii=False)
if action == "update_step":
if step_index < 0 or status not in ("pending", "in_progress", "done", "blocked"):
return json.dumps({"error": "需要有效的 step_index 和 status (pending/in_progress/done/blocked)"}, ensure_ascii=False)
for s in plan["steps"]:
if s["index"] == step_index:
s["status"] = status
plan["updated_at"] = datetime.now().isoformat()
break
with open(fpath, "w", encoding="utf-8") as f:
json.dump(plan, f, ensure_ascii=False, indent=2)
return json.dumps({"plan": plan, "updated_step": step_index, "new_status": status}, ensure_ascii=False)
if action == "delete":
os.remove(fpath)
return json.dumps({"deleted": plan_id}, ensure_ascii=False)
return json.dumps({"error": f"不支持的操作: {action},支持 create/list/view/update_step/delete"}, ensure_ascii=False)
TASK_PLAN_SCHEMA = {
"type": "function",
"function": {
"name": "task_plan",
"description": "管理复杂任务的分解和进度跟踪。可创建计划、查看进度、更新步骤状态。适合多步骤项目(如「开发用户系统」拆成 7 步逐一完成)。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "view", "update_step", "delete"],
"description": "操作create=创建计划, list=列出所有, view=查看详情, update_step=更新步骤状态, delete=删除",
},
"plan_id": {"type": "string", "description": "计划 IDcreate 可选,其他必填)"},
"title": {"type": "string", "description": "计划标题create 时必填)"},
"steps_json": {"type": "string", "description": "步骤列表 JSON 字符串,如 [\"分析需求\",\"设计架构\",\"编码实现\",\"测试验证\"]create 时必填)"},
"step_index": {"type": "integer", "description": "步骤序号update_step 时必填,从 0 开始)"},
"status": {"type": "string", "enum": ["pending", "in_progress", "done", "blocked"], "description": "步骤新状态"},
},
"required": ["action"],
},
},
}
# ── excel_process ─────────────────────────────────────────────
async def excel_process_tool(
file_path: str,
action: str = "read",
sheet_name: str = "",
data_json: str = "",
chart_config_json: str = "",
) -> str:
"""高级 Excel 处理:读写、创建图表、数据透视。"""
if not file_path:
return json.dumps({"error": "缺少 file_path"}, ensure_ascii=False)
if not os.path.isabs(file_path):
file_path = os.path.join(_local_file_workspace_root(), file_path)
try:
import openpyxl
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
from openpyxl.utils import get_column_letter
except ImportError:
return json.dumps({"error": "openpyxl 未安装,请执行 pip install openpyxl"}, ensure_ascii=False)
action = action.lower().strip()
if action == "read":
try:
wb = openpyxl.load_workbook(file_path, data_only=True)
except FileNotFoundError:
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
result = {"sheets": {}}
sheets = [sheet_name] if sheet_name else wb.sheetnames
for sn in sheets:
ws = wb[sn]
rows = []
for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), max_col=min(ws.max_column, 50), values_only=True):
rows.append(list(row))
result["sheets"][sn] = {
"rows": rows,
"row_count": len(rows),
"col_count": len(rows[0]) if rows else 0,
}
wb.close()
return json.dumps(result, ensure_ascii=False, default=str)
if action == "write":
try:
data = json.loads(data_json)
except json.JSONDecodeError:
return json.dumps({"error": "data_json 不是有效 JSON"}, ensure_ascii=False)
wb = openpyxl.Workbook()
for sn, rows in data.items() if isinstance(data, dict) else {"Sheet": data}.items():
if sn != list((data if isinstance(data, dict) else {"Sheet": data}).keys())[0]:
wb.create_sheet(sn)
ws = wb[sn]
for row_idx, row in enumerate(rows, 1):
for col_idx, val in enumerate(row, 1):
ws.cell(row=row_idx, column=col_idx, value=val)
wb.save(file_path)
wb.close()
return json.dumps({"file": file_path, "message": "写入成功"}, ensure_ascii=False)
if action == "chart":
try:
cfg = json.loads(chart_config_json) if chart_config_json else {}
except json.JSONDecodeError:
return json.dumps({"error": "chart_config_json 不是有效 JSON"}, ensure_ascii=False)
wb = openpyxl.load_workbook(file_path)
ws = wb[sheet_name] if sheet_name else wb.active
chart_type = cfg.get("type", "bar")
if chart_type == "bar":
chart = BarChart()
elif chart_type == "line":
chart = LineChart()
elif chart_type == "pie":
chart = PieChart()
else:
wb.close()
return json.dumps({"error": f"不支持的图表类型: {chart_type}"}, ensure_ascii=False)
chart.title = cfg.get("title", "Chart")
data = Reference(ws, min_col=cfg.get("data_min_col", 1), min_row=cfg.get("data_min_row", 1),
max_col=cfg.get("data_max_col", 2), max_row=cfg.get("data_max_row", ws.max_row))
chart.add_data(data, titles_from_data=True)
if "categories_min_col" in cfg:
cats = Reference(ws, min_col=cfg["categories_min_col"], min_row=cfg.get("data_min_row", 2),
max_row=cfg.get("data_max_row", ws.max_row))
chart.set_categories(cats)
chart_row = cfg.get("chart_row", ws.max_row + 3)
ws.add_chart(chart, f"A{chart_row}")
wb.save(file_path)
wb.close()
return json.dumps({"file": file_path, "chart_type": chart_type, "position": f"A{chart_row}", "message": "图表已添加"}, ensure_ascii=False)
return json.dumps({"error": f"不支持的操作: {action},支持 read/write/chart"}, ensure_ascii=False)
EXCEL_PROCESS_SCHEMA = {
"type": "function",
"function": {
"name": "excel_process",
"description": "高级 Excel 处理:读写数据、添加图表(柱状图/折线图/饼图)。需要 openpyxl。适合数据分析、报表生成。",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Excel 文件路径"},
"action": {
"type": "string",
"enum": ["read", "write", "chart"],
"description": "操作read=读取, write=写入, chart=添加图表",
},
"sheet_name": {"type": "string", "description": "工作表名称"},
"data_json": {"type": "string", "description": "写入数据 JSONwrite 时,格式 {\"Sheet1\": [[1,2],[3,4]]}"},
"chart_config_json": {"type": "string", "description": "图表配置 JSONchart 时,如 {\"type\":\"bar\",\"title\":\"销售额\"}"},
},
"required": ["file_path", "action"],
},
},
}
# ── browser_use ──────────────────────────────────────────────
async def browser_use_tool(
url: str,
action: str = "screenshot",
selector: str = "",
script: str = "",
wait_ms: int = 2000,
) -> str:
"""无头浏览器操控。需要 playwrightpip install playwright && playwright install chromium"""
import asyncio as _asyncio
try:
from playwright.async_api import async_playwright
except ImportError:
return json.dumps({
"error": "playwright 未安装",
"help": "安装方法: pip install playwright && playwright install chromium",
}, ensure_ascii=False)
action = action.lower().strip()
result = {}
async with async_playwright() as p:
try:
browser = await p.chromium.launch(headless=True)
except Exception:
return json.dumps({
"error": "无法启动 Chromium",
"help": "请运行: playwright install chromium",
}, ensure_ascii=False)
page = await browser.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
await _asyncio.sleep(wait_ms / 1000.0)
if action == "screenshot":
import tempfile
ss_path = os.path.join(
tempfile.gettempdir(),
f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
)
await page.screenshot(path=ss_path, full_page=True)
result["screenshot"] = ss_path
elif action == "content":
html = await page.content()
# 提取文本
text = await page.evaluate("() => document.body.innerText")
result["text"] = text[:8000]
result["text_length"] = len(text)
elif action == "click" and selector:
await page.click(selector)
result["clicked"] = selector
elif action == "fill" and selector and script:
await page.fill(selector, script)
result["filled"] = selector
elif action == "evaluate" and script:
val = await page.evaluate(script)
result["evaluate_result"] = str(val)[:4000]
else:
result["error"] = f"不支持的操作: {action},支持 screenshot/content/click/fill/evaluate"
except Exception as e:
result["error"] = str(e)
finally:
await browser.close()
return json.dumps(result, ensure_ascii=False)
BROWSER_USE_SCHEMA = {
"type": "function",
"function": {
"name": "browser_use",
"description": "无头浏览器操控:截图、提取内容、点击、填表、执行 JS。需要 playwright。适合网页测试、动态内容抓取。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "目标 URL"},
"action": {
"type": "string",
"enum": ["screenshot", "content", "click", "fill", "evaluate"],
"description": "操作类型",
},
"selector": {"type": "string", "description": "CSS 选择器click/fill 时必填)"},
"script": {"type": "string", "description": "要填入的文本或要执行的 JS 代码"},
"wait_ms": {"type": "integer", "description": "页面加载后等待毫秒数(默认 2000", "default": 2000},
},
"required": ["url", "action"],
},
},
}
# ── docker_manage ────────────────────────────────────────────
_DOCKER_ALLOWED_COMMANDS = [
"docker", "ps", "images", "logs", "stats", "inspect", "version", "info",
"compose", "ps", "logs", "config", "top",
]
async def docker_manage_tool(operation: str, resource: str = "", options: str = "") -> str:
"""Docker 管理操作(只读为主)。"""
import asyncio as _asyncio
op_parts = operation.strip().split()
# 检查命令是否安全
if not op_parts:
return json.dumps({"error": "缺少操作参数"}, ensure_ascii=False)
base = op_parts[0]
allowed_prefixes = {
"ps": ["docker", "ps"],
"images": ["docker", "images"],
"logs": ["docker", "logs"],
"stats": ["docker", "stats"],
"inspect": ["docker", "inspect"],
"version": ["docker", "version"],
"info": ["docker", "info"],
"compose": ["docker", "compose"],
}
if base not in allowed_prefixes:
return json.dumps({
"error": f"不支持的操作: {base}",
"allowed": sorted(allowed_prefixes.keys()),
}, ensure_ascii=False)
cmd = list(allowed_prefixes[base])
if resource:
cmd.append(resource)
# 解析额外选项(从 options 字符串拆分)
if options:
import shlex
try:
cmd.extend(shlex.split(options))
except ValueError:
cmd.extend(options.split())
# compose 只读子命令限制
if base == "compose" and resource not in ("ps", "logs", "config", "top", ""):
return json.dumps({"error": f"docker compose {resource} 不被允许(仅限只读操作)"}, ensure_ascii=False)
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=30)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:8000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Docker 未安装或不在 PATH 中"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "Docker 操作超时"}, ensure_ascii=False)
DOCKER_MANAGE_SCHEMA = {
"type": "function",
"function": {
"name": "docker_manage",
"description": "Docker 管理(只读为主):查看容器列表、镜像、日志、资源使用、容器详情。需要 Docker CLI。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["ps", "images", "logs", "stats", "inspect", "version", "info", "compose"],
"description": "操作类型",
},
"resource": {"type": "string", "description": "容器名/镜像名/服务名"},
"options": {"type": "string", "description": "额外命令行选项"},
},
"required": ["operation"],
},
},
}
# ── deploy_push ──────────────────────────────────────────────
async def deploy_push_tool(
source_path: str,
target: str,
method: str = "copy",
exclude: str = ".git,__pycache__,node_modules,.venv,venv",
) -> str:
"""文件部署本地复制、SCP 或 RSYNC。"""
import asyncio as _asyncio
if not os.path.isabs(source_path):
source_path = os.path.join(_local_file_workspace_root(), source_path)
source_path = os.path.abspath(source_path)
if not os.path.exists(source_path):
return json.dumps({"error": f"源路径不存在: {source_path}"}, ensure_ascii=False)
method = method.lower().strip()
if method == "copy":
import shutil
target = os.path.abspath(target) if target else os.path.join(_local_file_workspace_root(), "deploy")
try:
if os.path.isdir(source_path):
if os.path.exists(target):
shutil.rmtree(target)
# 构建 rsync-like exclude
exclude_set = set(e.strip() for e in exclude.split(",") if e.strip())
shutil.copytree(source_path, target, ignore=shutil.ignore_patterns(*exclude_set))
else:
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
shutil.copy2(source_path, target)
return json.dumps({"method": "copy", "source": source_path, "target": target, "status": "ok"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"复制失败: {e}"}, ensure_ascii=False)
elif method == "rsync":
exclude_args = []
for e in exclude.split(","):
e = e.strip()
if e:
exclude_args.extend(["--exclude", e])
cmd = ["rsync", "-avz", "--progress"] + exclude_args + [source_path + "/" if os.path.isdir(source_path) else source_path, target]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=120)
return json.dumps({
"method": "rsync",
"source": source_path,
"target": target,
"stdout": stdout.decode("utf-8", errors="replace")[:4000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "rsync 未安装"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "rsync 超时"}, ensure_ascii=False)
else:
return json.dumps({"error": f"不支持的部署方式: {method},支持 copy/rsync"}, ensure_ascii=False)
DEPLOY_PUSH_SCHEMA = {
"type": "function",
"function": {
"name": "deploy_push",
"description": "部署文件到目标位置:本地复制(支持 exclude 规则)或 rsync 远程同步。适合将项目部署到服务器。",
"parameters": {
"type": "object",
"properties": {
"source_path": {"type": "string", "description": "源文件或目录路径"},
"target": {"type": "string", "description": "目标路径(本地目录或 rsync 远程地址如 user@host:/path"},
"method": {
"type": "string",
"enum": ["copy", "rsync"],
"description": "部署方式copy=本地复制, rsync=rsync 同步",
"default": "copy",
},
"exclude": {"type": "string", "description": "排除模式(逗号分隔,如 .git,node_modules", "default": ".git,__pycache__,node_modules,.venv,venv"},
},
"required": ["source_path", "target"],
},
},
}
# ── agent_create ─────────────────────────────────────────────
async def agent_create_tool(
name: str,
system_prompt: str,
description: str = "",
model: str = "deepseek-v4-flash",
provider: str = "deepseek",
temperature: float = 0.7,
) -> str:
"""
动态创建专业子 Agent。
当本 Agent 发现某个任务超出自身专业领域时,可创建一个专门的子 Agent 来处理,
创建后通过 agent_call 委派任务。新 Agent 会持久化到数据库,后续可直接复用。
Args:
name: 新 Agent 名称建议体现专业领域如「SQL优化专家」
system_prompt: 新 Agent 的系统提示词,定义其角色和专业能力
description: 简短描述
model: 使用的模型
provider: 模型提供商
temperature: 温度参数
"""
import uuid
# -- 参数校验 --
if not name or not name.strip():
return json.dumps({"error": "invalid_name", "message": "Agent 名称不能为空"}, ensure_ascii=False)
name = name.strip()
if not system_prompt or len(system_prompt.strip()) < 20:
return json.dumps({
"error": "invalid_prompt",
"message": f"system_prompt 太短({len(system_prompt) if system_prompt else 0}字符至少需要20字符才能定义有效的 Agent 角色",
}, ensure_ascii=False)
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.models.user import User
db = SessionLocal()
try:
# 检查重名
existing = db.query(Agent).filter(Agent.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"Agent「{name}」已存在id={existing.id}),可直接通过 agent_call 调用",
"existing_id": existing.id,
}, ensure_ascii=False)
# 取第一个用户作为所有者
owner = db.query(User).first()
if not owner:
db.close()
return json.dumps({"error": "no_user", "message": "数据库中无用户,无法创建 Agent"}, ensure_ascii=False)
agent_id = str(uuid.uuid4())
agent = Agent(
id=agent_id,
name=name,
description=description or f"由 Agent 自主创建的专业子 Agent{name}",
workflow_config={
"nodes": [
{"id": "start-1", "type": "start", "position": {"x": 80, "y": 120}, "data": {}},
{
"id": "agent-1",
"type": "agent",
"position": {"x": 320, "y": 120},
"data": {
"label": name,
"system_prompt": system_prompt,
"model": model,
"provider": provider,
"temperature": temperature,
"max_iterations": 10,
"tools": [],
"memory": True,
},
},
{"id": "end-1", "type": "end", "position": {"x": 560, "y": 120}, "data": {}},
],
"edges": [
{"id": "e_start_agent", "source": "start-1", "target": "agent-1", "sourceHandle": "right", "targetHandle": "left"},
{"id": "e_agent_end", "source": "agent-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"},
],
},
status="active",
user_id=owner.id,
)
db.add(agent)
db.commit()
result = {
"status": "created",
"agent": {
"id": agent_id,
"name": name,
"description": agent.description,
"model": model,
"provider": provider,
"temperature": temperature,
"tools": "ALL (34 builtin tools)",
"memory": "RAG enabled",
},
"hint": f"现在可以用 agent_call(agent_name=\"{name}\", query=\"...\") 来调用这个新 Agent",
}
return json.dumps(result, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"agent_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "creation_failed", "message": f"创建 Agent 失败: {e}"}, ensure_ascii=False)
AGENT_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "agent_create",
"description": (
"动态创建一个专业子 Agent。当本 Agent 发现某个任务超出自身专业领域时,"
"用此工具创建专门 Agent然后用 agent_call 委派任务。"
"新 Agent 会持久化,后续可直接复用。"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "新 Agent 名称如「SQL优化专家」「日语翻译官」"},
"system_prompt": {"type": "string", "description": "新 Agent 的系统提示词,定义其角色、专业能力和行为准则"},
"description": {"type": "string", "description": "简短描述(可选)"},
"model": {"type": "string", "description": "使用的模型", "default": "deepseek-v4-flash"},
"provider": {"type": "string", "description": "模型提供商", "default": "deepseek"},
"temperature": {"type": "number", "description": "温度参数", "default": 0.7},
},
"required": ["name", "system_prompt"],
},
},
}
# ── tool_register ────────────────────────────────────────────
async def tool_register_tool(
name: str,
description: str,
method: str = "GET",
url: str = "",
headers_json: str = "{}",
body_template_json: str = "",
) -> str:
"""
动态注册 HTTP 工具。
当 Agent 发现实用的外部 API 时,将其注册为可复用工具。
工具写入数据库后立即加载到 tool_registry后续所有 Agent 均可直接调用。
Args:
name: 工具名称(英文,如 stock_price、weather_query
description: 工具功能描述
method: HTTP 方法
url: 请求 URL 模板,参数用 {param_name} 占位,如 https://api.example.com/stock?symbol={symbol}
headers_json: 请求头 JSON 字符串
body_template_json: 请求体模板 JSON 字符串
"""
import uuid
import re as _re
try:
from app.core.database import SessionLocal
from app.models.tool import Tool
from app.models.user import User
from app.services.tool_registry import tool_registry
# 解析 URL 模板参数 {param_name}
url_params = _re.findall(r"\{(\w+)\}", url)
body_params = []
if body_template_json:
try:
body_tpl = json.loads(body_template_json)
body_str = json.dumps(body_tpl)
body_params = _re.findall(r"\{(\w+)\}", body_str)
except json.JSONDecodeError:
return json.dumps({"error": "body_template_json 不是有效 JSON"}, ensure_ascii=False)
# 构建 OpenAI function schema
properties = {}
required = []
for p in sorted(set(url_params + body_params)):
properties[p] = {"type": "string", "description": f"参数 {p}"}
required.append(p)
# headers 参数
try:
hdrs = json.loads(headers_json)
for hk in hdrs:
if "{" in str(hdrs[hk]):
continue # 模板头跳过
except json.JSONDecodeError:
pass
function_schema = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
# URL 可达性验证(发送 HEAD 请求检测,不阻塞注册)
url_reachable = False
try:
# 用占位符替换模板参数为 test 值,构造测试 URL
test_url = url
for p in url_params:
test_url = test_url.replace(f"{{{p}}}", "test")
if not url_params:
test_url = url
resp = await httpx.AsyncClient(timeout=5.0).head(test_url, follow_redirects=True)
url_reachable = resp.status_code < 500
except Exception:
url_reachable = False # 不阻断注册,仅记录
db = SessionLocal()
try:
# 检查重名
existing = db.query(Tool).filter(Tool.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"工具「{name}」已存在id={existing.id}",
"existing_id": existing.id,
}, ensure_ascii=False)
owner = db.query(User).first()
tool_id = str(uuid.uuid4())
tool = Tool(
id=tool_id,
name=name,
description=description,
category="agent_created",
function_schema=function_schema,
implementation_type="http",
implementation_config={
"url": url,
"method": method.upper(),
"headers": json.loads(headers_json) if headers_json else {},
"body_template": json.loads(body_template_json) if body_template_json else None,
},
is_public=False,
user_id=owner.id if owner else None,
)
db.add(tool)
db.commit()
# 立刻加载到 tool_registry
tool_registry.load_tools_from_db(db, tool_names=[name])
return json.dumps({
"status": "registered",
"tool": {
"id": tool_id,
"name": name,
"description": description,
"method": method.upper(),
"url_template": url,
"params": required,
},
"url_reachable": url_reachable,
"hint": f"工具已就绪,可直接调用 {name}({', '.join(p + '=...' for p in required) if required else ''})",
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"tool_register 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "registration_failed", "message": f"注册工具失败: {e}"}, ensure_ascii=False)
TOOL_REGISTER_SCHEMA = {
"type": "function",
"function": {
"name": "tool_register",
"description": (
"动态注册一个新 HTTP 工具。当 Agent 发现实用的外部 API 时,"
"将其注册为可复用工具。工具写入数据库后立即可用,后续所有 Agent 均可调用。"
"URL 中用 {param_name} 占位参数,如 https://api.example.com/weather?city={city}"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "工具名称(英文,如 stock_price、weather_query"},
"description": {"type": "string", "description": "工具功能描述"},
"method": {"type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "description": "HTTP 方法", "default": "GET"},
"url": {"type": "string", "description": "请求 URL 模板,参数用 {param_name} 占位"},
"headers_json": {"type": "string", "description": "请求头 JSON可选", "default": "{}"},
"body_template_json": {"type": "string", "description": "请求体模板 JSONPOST/PUT 时可选)"},
},
"required": ["name", "description", "url"],
},
},
}
# ── capability_check ──────────────────────────────────────────
async def capability_check_tool(
task_description: str,
required_domains: str = "",
) -> str:
"""
能力自检工具:分析当前 Agent 的工具和子 Agent 是否足以完成给定任务。
Agent 在接到复杂/陌生任务时,应先调用此工具评估自身能力,
根据返回的差距分析决定是否需要创建子 Agent 或注册新工具。
Args:
task_description: 任务描述
required_domains: 任务涉及的领域,逗号分隔(如「数据库,性能优化,SQL」
"""
try:
from app.core.database import SessionLocal
from app.models.agent import Agent as AgentModel
from app.services.tool_registry import tool_registry
# 获取所有可用工具
all_tool_names = []
tool_categories = {}
for schema in tool_registry.get_all_tool_schemas():
fn = schema.get("function", schema)
tname = fn.get("name", "")
if tname:
all_tool_names.append(tname)
desc = fn.get("description", "")
# 简单分类
cat = desc.split("")[0] if "" in desc else desc[:30]
tool_categories[tname] = cat
# 查询数据库中的可用子 Agent
available_agents = []
try:
db = SessionLocal()
try:
agents_in_db = db.query(AgentModel).filter(AgentModel.status == "active").all()
available_agents = [
{"name": a.name, "id": a.id, "description": a.description or ""}
for a in agents_in_db
]
finally:
db.close()
except Exception:
pass
# 领域关键词映射(规则引擎,不调 LLM
domain_tool_map = {
"数据库": ["database_query"],
"SQL": ["database_query"],
"代码": ["code_execute", "git_operation"],
"编程": ["code_execute", "git_operation", "project_scaffold"],
"文件": ["file_read", "file_write", "excel_process"],
"网络": ["http_request", "url_parse", "web_search"],
"HTTP": ["http_request"],
"API": ["http_request", "tool_register"],
"数据": ["json_process", "excel_process", "text_analyze"],
"分析": ["text_analyze", "code_execute", "database_query"],
"报告": ["pdf_generate", "file_write"],
"文档": ["file_read", "pdf_generate", "file_write"],
"部署": ["deploy_push", "docker_manage"],
"容器": ["docker_manage"],
"Docker": ["docker_manage"],
"前端": ["project_scaffold"],
"后端": ["project_scaffold", "http_request"],
"搜索": ["web_search"],
"Git": ["git_operation"],
"测试": ["code_execute"],
"监控": ["http_request", "schedule_create"],
"调度": ["schedule_create", "schedule_list"],
"邮件": ["send_email"],
"Excel": ["excel_process"],
"PDF": ["pdf_generate", "file_read"],
"浏览器": ["browser_use"],
"Android": ["adb_log"],
"加密": ["crypto_util"],
"性能": ["database_query", "code_execute"],
"优化": ["database_query", "code_execute"],
}
# 工具能力覆盖关键词
tool_capability_keywords = {
"http_request": ["HTTP", "API", "网络", "请求"],
"file_read": ["文件", "读取"],
"file_write": ["文件", "写入", "保存"],
"text_analyze": ["文本", "分析"],
"datetime": ["时间", "日期"],
"math_calculate": ["数学", "计算"],
"system_info": ["系统", "信息"],
"json_process": ["JSON", "数据"],
"database_query": ["数据库", "SQL", "查询"],
"adb_log": ["Android", "ADB"],
"schedule_create": ["调度", "定时"],
"crypto_util": ["加密", "解密"],
"random_generate": ["随机", "数据"],
"send_email": ["邮件", "发送"],
"url_parse": ["URL", "解析"],
"regex_test": ["正则", "表达式"],
"agent_call": ["Agent", "委派"],
"code_execute": ["代码", "执行", "Python"],
"git_operation": ["Git", "版本"],
"web_search": ["搜索", "网页"],
"pdf_generate": ["PDF", "报告"],
"project_scaffold": ["项目", "模板"],
"task_plan": ["任务", "规划"],
"excel_process": ["Excel"],
"browser_use": ["浏览器"],
"docker_manage": ["Docker", "容器"],
"deploy_push": ["部署"],
"agent_create": ["创建Agent"],
"tool_register": ["注册工具"],
}
domains = [d.strip() for d in required_domains.split(",") if d.strip()]
if not domains:
# 从 task_description 中自动提取关键词
desc_lower = task_description
domains = [kw for kw in domain_tool_map if kw in desc_lower]
if not domains:
domains = ["通用"]
# 找出推荐的已有工具
recommended_existing = []
for domain in domains:
tools = domain_tool_map.get(domain, [])
for t in tools:
if t in all_tool_names and t not in recommended_existing:
recommended_existing.append(t)
# 差距分析:哪些领域没有匹配的工具
missing_domains = []
missing_tools = []
for domain in domains:
expected = domain_tool_map.get(domain)
if expected:
found = any(t in all_tool_names for t in expected)
if not found:
missing_domains.append(domain)
missing_tools.extend(expected)
missing_tools = sorted(set(missing_tools))
# 严重程度评估
if not missing_domains:
severity = "low"
elif len(missing_domains) <= 2:
severity = "medium"
else:
severity = "high"
# 生成建议
recommendations = []
if missing_domains:
rec_name = f"{domains[0]}专家" if domains else "领域专家"
recommendations.append(f"使用 agent_create 创建「{rec_name}」Agent赋予其专业知识")
if missing_tools:
actionable = [t for t in missing_tools if t in tool_capability_keywords]
if actionable:
recommendations.append(
f"缺少以下能力工具:{', '.join(actionable[:5])}"
f"可通过 tool_register 注册外部 API或通过 code_tool_create 创建代码工具"
)
recommendations.append("使用 web_search 搜索相关的外部 API 或开源工具")
if available_agents:
agent_names = [a["name"] for a in available_agents[:5]]
recommendations.insert(0, f"可尝试调用现有 Agent{', '.join(agent_names)}")
result = {
"task": task_description,
"domains_analyzed": domains,
"available_tools_count": len(all_tool_names),
"available_tools_sample": all_tool_names[:15],
"available_agents": [a["name"] for a in available_agents],
"recommended_existing_tools": recommended_existing,
"gap_analysis": {
"missing_domain_knowledge": missing_domains,
"missing_tools": missing_tools,
"severity": severity,
},
"recommendations": recommendations,
}
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.error(f"capability_check 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "check_failed", "message": str(e)}, ensure_ascii=False)
CAPABILITY_CHECK_SCHEMA = {
"type": "function",
"function": {
"name": "capability_check",
"description": (
"能力自检工具:分析当前可用工具和子 Agent 是否足以完成给定任务。"
"在接到复杂或陌生任务时,先用此工具评估差距,再决定是否需要创建子 Agent 或注册新工具。"
"返回结构化差距分析和行动建议。"
),
"parameters": {
"type": "object",
"properties": {
"task_description": {"type": "string", "description": "任务描述如「分析MySQL慢查询日志并优化」"},
"required_domains": {"type": "string", "description": "任务涉及的领域,逗号分隔(如「数据库,性能优化,SQL」留空则自动提取"},
},
"required": ["task_description"],
},
},
}
# ── code_tool_create ──────────────────────────────────────────
async def code_tool_create_tool(
name: str,
description: str,
code: str,
language: str = "python",
parameter_schema: str = "{}",
test_input: str = "",
) -> str:
"""
将经过验证的代码持久化为可复用工具。
先用 code_execute 验证代码正确性,再写入 tools 表并加载到 tool_registry。
创建后所有 Agent 均可调用该工具。
Args:
name: 工具名称(英文,如 parse_slow_query
description: 工具功能描述
code: 完整的 Python/JS 函数代码
language: 代码语言python/javascript
parameter_schema: 参数 schema JSON{"param1": {"type": "string", "description": "..."}}
test_input: 测试输入 JSON用于沙箱验证可选
"""
import uuid
try:
from app.core.database import SessionLocal
from app.models.tool import Tool
from app.models.user import User
from app.services.tool_registry import tool_registry
# 解析参数 schema
try:
param_schema = json.loads(parameter_schema) if parameter_schema else {}
except json.JSONDecodeError:
return json.dumps({"error": "invalid_schema", "message": "parameter_schema 不是有效 JSON"}, ensure_ascii=False)
# 构建 OpenAI function schema
properties = {}
required_params = []
for pname, pinfo in param_schema.items():
prop = {"description": pinfo.get("description", f"参数 {pname}")}
ptype = pinfo.get("type", "string")
if ptype == "integer":
prop["type"] = "integer"
elif ptype == "number":
prop["type"] = "number"
elif ptype == "boolean":
prop["type"] = "boolean"
else:
prop["type"] = "string"
properties[pname] = prop
if pinfo.get("required", True):
required_params.append(pname)
function_schema = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required_params,
},
},
}
# 沙箱测试(如果提供了 test_input
test_result = None
if test_input and language == "python":
try:
test_params = json.loads(test_input) if isinstance(test_input, str) else test_input
# 构建测试调用代码
args_str = ", ".join(
f"{k}={repr(v)}" if not isinstance(v, (int, float, bool)) else f"{k}={v}"
for k, v in test_params.items()
)
test_code = f"{code}\n\n# test\nresult = {name}({args_str})\nprint(json.dumps(result, ensure_ascii=False, default=str))"
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", test_code,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
if proc.returncode == 0:
test_result = {"passed": True, "output": stdout.decode("utf-8", errors="replace").strip()[:2000]}
else:
return json.dumps({
"error": "test_failed",
"message": f"沙箱测试失败,请修正代码后重试",
"stderr": stderr.decode("utf-8", errors="replace")[:1000],
}, ensure_ascii=False)
except asyncio.TimeoutError:
return json.dumps({"error": "test_timeout", "message": "沙箱测试超时15秒请优化代码"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": "test_error", "message": f"沙箱测试异常: {e}"}, ensure_ascii=False)
# 写入 tools 表
db = SessionLocal()
try:
existing = db.query(Tool).filter(Tool.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"工具「{name}」已存在id={existing.id}",
"existing_id": existing.id,
}, ensure_ascii=False)
owner = db.query(User).first()
tool_id = str(uuid.uuid4())
tool = Tool(
id=tool_id,
name=name,
description=description,
category="agent_created",
function_schema=function_schema,
implementation_type="code",
implementation_config={
"code": code,
"language": language,
"parameter_schema": param_schema,
},
is_public=False,
user_id=owner.id if owner else None,
)
db.add(tool)
db.commit()
# 加载到 tool_registry
tool_registry.load_tools_from_db(db, tool_names=[name])
result = {
"status": "created",
"tool": {
"id": tool_id,
"name": name,
"description": description,
"language": language,
"params": required_params,
},
"sandbox_test": test_result,
"hint": f"工具已就绪,可直接调用 {name}({', '.join(p + '=...' for p in required_params) if required_params else ''})",
}
return json.dumps(result, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"code_tool_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "creation_failed", "message": f"创建代码工具失败: {e}"}, ensure_ascii=False)
CODE_TOOL_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "code_tool_create",
"description": (
"将经过验证的代码持久化为可复用的内置工具。"
"先用 code_execute 验证代码,确认正确后调用此工具将其注册为永久工具。"
"创建后所有 Agent 均可直接调用该工具。"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "工具名称(英文,如 parse_slow_query、csv_cleaner"},
"description": {"type": "string", "description": "工具功能描述中文如「解析MySQL慢查询日志提取最慢的N条SQL」"},
"code": {"type": "string", "description": "完整的 Python 函数代码(包含 def 定义和依赖 import"},
"language": {"type": "string", "description": "代码语言", "default": "python"},
"parameter_schema": {"type": "string", "description": "参数 JSON schema{\"log_path\": {\"type\": \"string\", \"description\": \"日志路径\"}}", "default": "{}"},
"test_input": {"type": "string", "description": "测试输入 JSON用于沙箱验证可选但推荐提供"},
},
"required": ["name", "description", "code"],
},
},
}
# ── extension_log ────────────────────────────────────────────
async def extension_log_tool(
action: str = "list",
name: str = "",
extension_type: str = "",
reason: str = "",
detail_json: str = "{}",
success_rating: str = "",
note: str = "",
limit: int = 10,
) -> str:
"""
记录和查询 Agent 自主扩展历史。
Agent 创建子 Agent / 注册工具 / 创建代码工具后,用此工具记录。
后续可查询历史扩展记录进行自我反思。
Args:
action: "list" 查询历史 / "log" 记录新扩展 / "evaluate" 评价已有扩展
name: 扩展名称log/evaluate 时必填)
extension_type: 扩展类型agent_created/tool_registered/code_tool_created
reason: 创建原因
detail_json: 扩展详情 JSON
success_rating: 效果评价success/partial/failed
note: 备注
limit: list 时返回的最大条数
"""
import uuid
from datetime import datetime
try:
from app.core.database import SessionLocal
from app.models.agent import AgentExtension
from app.models.user import User
db = SessionLocal()
try:
if action == "list":
query = db.query(AgentExtension).order_by(AgentExtension.created_at.desc())
if extension_type:
query = query.filter(AgentExtension.extension_type == extension_type)
if name:
query = query.filter(AgentExtension.name.contains(name))
extensions = query.limit(min(limit, 50)).all()
items = []
for ext in extensions:
items.append({
"id": ext.id,
"type": ext.extension_type,
"name": ext.name,
"reason": ext.reason,
"success_rating": ext.success_rating,
"note": ext.note,
"created_at": ext.created_at.isoformat() if ext.created_at else None,
})
return json.dumps({
"extensions": items,
"count": len(items),
"hint": "使用 extension_log(action='evaluate', name='...', success_rating='success') 评价扩展效果",
}, ensure_ascii=False)
elif action == "log":
if not name or not extension_type:
return json.dumps({"error": "missing_fields", "message": "log 操作需要 name 和 extension_type 参数"}, ensure_ascii=False)
owner = db.query(User).first()
ext_id = str(uuid.uuid4())
try:
detail = json.loads(detail_json) if detail_json else {}
except json.JSONDecodeError:
detail = {"raw": detail_json}
ext_record = AgentExtension(
id=ext_id,
extension_type=extension_type,
name=name,
reason=reason,
detail=detail,
user_id=owner.id if owner else None,
)
db.add(ext_record)
db.commit()
return json.dumps({
"status": "logged",
"extension": {"id": ext_id, "type": extension_type, "name": name},
"hint": "扩展已记录。后续可用 extension_log(action='evaluate', ...) 评价效果",
}, ensure_ascii=False)
elif action == "evaluate":
if not name:
return json.dumps({"error": "missing_fields", "message": "evaluate 操作需要 name 参数"}, ensure_ascii=False)
# 查找最近的匹配记录
ext_record = (
db.query(AgentExtension)
.filter(AgentExtension.name == name)
.order_by(AgentExtension.created_at.desc())
.first()
)
if not ext_record:
return json.dumps({
"error": "not_found",
"message": f"未找到名为「{name}」的扩展记录,请先用 action='log' 记录",
}, ensure_ascii=False)
if success_rating:
ext_record.success_rating = success_rating
if note:
ext_record.note = note
db.commit()
return json.dumps({
"status": "evaluated",
"extension": {"id": ext_record.id, "name": name, "success_rating": success_rating, "note": note},
}, ensure_ascii=False)
else:
return json.dumps({"error": "unknown_action", "message": f"未知操作: {action},支持 list/log/evaluate"}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"extension_log 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "extension_log_failed", "message": str(e)}, ensure_ascii=False)
EXTENSION_LOG_SCHEMA = {
"type": "function",
"function": {
"name": "extension_log",
"description": (
"记录和查询自主扩展历史。创建子 Agent / 注册工具 / 创建代码工具后,"
"用此工具记录扩展和原因,以便后续反思和优化。"
"支持 list查询、log记录、evaluate评价三种操作。"
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "log", "evaluate"], "description": "操作list 查询 / log 记录 / evaluate 评价", "default": "list"},
"name": {"type": "string", "description": "扩展名称Agent名或工具名log/evaluate 时必填)"},
"extension_type": {"type": "string", "enum": ["agent_created", "tool_registered", "code_tool_created"], "description": "扩展类型log 时必填)"},
"reason": {"type": "string", "description": "创建原因简短说明为什么要扩展log 时推荐填写)"},
"detail_json": {"type": "string", "description": "扩展详情 JSONlog 时可选)"},
"success_rating": {"type": "string", "enum": ["success", "partial", "failed"], "description": "效果评价evaluate 时使用)"},
"note": {"type": "string", "description": "反馈备注evaluate 时使用如「子Agent成功定位3条慢SQL」"},
"limit": {"type": "integer", "description": "list 时返回的最大条数", "default": 10},
},
"required": ["action"],
},
},
}
# ── self_review ──────────────────────────────────────────────
async def self_review_tool(
content: str,
criteria: str,
task_context: str = "",
) -> str:
"""
输出质量自检工具:用 LLM 评判一段内容是否满足质量标准。
Agent 或工作流节点在执行完毕后,可调用此工具对输出进行质量评估。
评分 >= 0.6 为通过,不通过时返回具体问题和改进建议,供修正参考。
Args:
content: 待评估的内容Agent 回答、报告、代码等)
criteria: 评判标准如「回答必须包含索引优化建议和具体SQL示例」
task_context: 原始任务上下文(可选,帮助评判器理解背景)
"""
try:
# 构建评判 prompt
judge_prompt = (
"你是严格的内容质量评审专家。请根据以下标准对内容进行评分。\n\n"
f"【评判标准】\n{criteria}\n\n"
f"【待评审内容】\n{content[:8000]}\n"
)
if task_context:
judge_prompt += f"\n【任务背景】\n{task_context[:2000]}\n"
judge_prompt += (
"\n请以 JSON 格式返回评审结果(严格只返回 JSON不要任何其他文字\n"
'{"score": 0.75, "passed": true, "issues": ["问题1", "问题2"], '
'"suggestions": ["建议1", "建议2"], "summary": "一句话总结"}\n\n'
"评分规则:\n"
"- 1.0 完美满足所有标准\n"
"- 0.8 良好,有小的改进空间\n"
"- 0.6 基本满足,但存在明显不足\n"
"- 0.4 大部分标准未满足\n"
"- 0.2 完全不满足\n"
"- score >= 0.6 时 passed=true否则 passed=false\n"
)
# 调用轻量 LLM 评审
from app.services.llm_service import LLMService
llm = LLMService()
messages = [{"role": "user", "content": judge_prompt}]
resp = await llm.chat(
provider="deepseek",
model="deepseek-v4-flash",
messages=messages,
temperature=0.1,
max_tokens=800,
)
judge_text = resp.get("content", "") if isinstance(resp, dict) else str(resp)
# 解析 JSON 响应
try:
# 清理可能的 markdown 包裹
judge_text_clean = judge_text.strip()
if judge_text_clean.startswith("```"):
lines = judge_text_clean.split("\n")
judge_text_clean = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(judge_text_clean)
except json.JSONDecodeError:
# 尝试从文本中提取 JSON
import re as _re2
m = _re2.search(r'\{[^{}]*"score"\s*:\s*[\d.]+[^{}]*\}', judge_text, _re2.DOTALL)
if m:
try:
result = json.loads(m.group())
except json.JSONDecodeError:
result = {"score": 0.5, "passed": False, "issues": ["无法解析评审结果"], "suggestions": [], "summary": judge_text[:200]}
else:
result = {"score": 0.5, "passed": False, "issues": ["无法解析评审结果"], "suggestions": [], "summary": judge_text[:200]}
score = float(result.get("score", 0.5))
passed = result.get("passed", score >= 0.6)
if not passed and score >= 0.6:
passed = True
if passed and score < 0.6:
passed = False
return json.dumps({
"score": score,
"passed": passed,
"threshold": 0.6,
"issues": result.get("issues", []),
"suggestions": result.get("suggestions", []),
"summary": result.get("summary", ""),
}, ensure_ascii=False)
except Exception as e:
logger.error(f"self_review 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "review_failed", "message": str(e), "score": 0, "passed": False}, ensure_ascii=False)
SELF_REVIEW_SCHEMA = {
"type": "function",
"function": {
"name": "self_review",
"description": (
"输出质量自检工具:用 LLM 评判一段内容的输出质量。"
"在完成任务后调用此工具检查输出是否满足标准,"
"不通过时会给出具体问题和改进建议。"
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "待评估的内容Agent 回答、报告、代码等)"},
"criteria": {"type": "string", "description": "评判标准如「回答必须包含索引优化建议和具体SQL示例」"},
"task_context": {"type": "string", "description": "原始任务上下文(可选,帮助评判器理解背景)"},
},
"required": ["content", "criteria"],
},
},
}