Files
AgentLab/backend/app/api/tools.py
T
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00

256 lines
8.5 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
from app.db.models import CustomTool, McpServer
from app.tools.custom import run_custom_tool
from app.tools.mcp_manager import mcp_manager
from app.tools.registry import ToolContext, all_specs, call_tool
router = APIRouter(prefix="/api/tools", tags=["tools"])
@router.get("")
async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
"""把内置、自定义、MCP 三类工具拉平成一个列表,前端节点面板直接用。"""
out: list[dict[str, Any]] = []
for name, spec in sorted(all_specs().items()):
out.append(
{
"id": name,
"name": name,
"description": spec.description,
"category": spec.category,
"source": "builtin",
"dangerous": spec.dangerous,
"schema": spec.json_schema(),
}
)
rows = (
await session.execute(select(CustomTool).where(CustomTool.enabled.is_(True)))
).scalars()
for row in rows:
out.append(
{
"id": row.name,
"name": row.name,
"description": row.description,
"category": f"自定义 · {row.kind}",
"source": "custom",
"dangerous": True,
"schema": row.parameters or {},
}
)
try:
out.extend({**t, "source": "mcp", "dangerous": True} for t in await mcp_manager.list_tools())
except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉
pass
return out
class RunToolIn(BaseModel):
args: dict[str, Any] = Field(default_factory=dict)
sandbox_session: str = "playground"
@router.post("/{tool_name:path}/run")
async def run_tool(
tool_name: str, payload: RunToolIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""直接试跑一个工具。工具面板上的"试一下"用它,不用为了测工具去搭一张图。"""
ctx = ToolContext(run_id="playground", node_id="playground",
sandbox_session=payload.sandbox_session)
import time
started = time.perf_counter()
try:
result = await call_tool(tool_name, payload.args, ctx, session=session)
except KeyError as e:
raise HTTPException(404, str(e)) from e
except Exception as e: # noqa: BLE001
return {
"ok": False,
"error": f"{type(e).__name__}: {e}",
"duration_ms": int((time.perf_counter() - started) * 1000),
}
return {
"ok": True,
"result": result,
"duration_ms": int((time.perf_counter() - started) * 1000),
}
# --------------------------------------------------------------------------
# 自定义工具
# --------------------------------------------------------------------------
custom_router = APIRouter(prefix="/api/custom-tools", tags=["tools"])
class CustomToolIn(BaseModel):
name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$")
description: str = ""
kind: str = "http" # http | python
parameters: dict[str, Any] = Field(default_factory=dict)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class CustomToolOut(CustomToolIn):
id: str
model_config = {"from_attributes": True}
@custom_router.get("", response_model=list[CustomToolOut])
async def list_custom(session: AsyncSession = Depends(get_session)) -> list[CustomTool]:
return list((await session.execute(select(CustomTool))).scalars())
@custom_router.post("", response_model=CustomToolOut, status_code=201)
async def create_custom(
payload: CustomToolIn, session: AsyncSession = Depends(get_session)
) -> CustomTool:
if (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none():
raise HTTPException(409, "同名工具已存在")
if payload.name in all_specs():
raise HTTPException(409, "这个名字和内置工具冲突")
row = CustomTool(**payload.model_dump())
session.add(row)
await session.commit()
await session.refresh(row)
return row
@custom_router.patch("/{tool_id}", response_model=CustomToolOut)
async def update_custom(
tool_id: str, payload: CustomToolIn, session: AsyncSession = Depends(get_session)
) -> CustomTool:
row = await session.get(CustomTool, tool_id)
if not row:
raise HTTPException(404, "工具不存在")
for key, value in payload.model_dump().items():
setattr(row, key, value)
await session.commit()
await session.refresh(row)
return row
@custom_router.delete("/{tool_id}", status_code=204)
async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_session)) -> None:
row = await session.get(CustomTool, tool_id)
if not row:
raise HTTPException(404, "工具不存在")
await session.delete(row)
await session.commit()
@custom_router.post("/{tool_id}/test")
async def test_custom(
tool_id: str, payload: RunToolIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
row = await session.get(CustomTool, tool_id)
if not row:
raise HTTPException(404, "工具不存在")
ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground")
try:
result = await run_custom_tool(session, row.name, payload.args, ctx)
except Exception as e: # noqa: BLE001
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "result": result}
# --------------------------------------------------------------------------
# MCP
# --------------------------------------------------------------------------
mcp_router = APIRouter(prefix="/api/mcp", tags=["tools"])
class McpIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
transport: str = "stdio" # stdio | http
command: str | None = None
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
url: str | None = None
enabled: bool = True
class McpOut(McpIn):
id: str
status: str
last_error: str | None
tools_cache: list[Any]
model_config = {"from_attributes": True}
@mcp_router.get("/servers", response_model=list[McpOut])
async def list_servers(session: AsyncSession = Depends(get_session)) -> list[McpServer]:
return list((await session.execute(select(McpServer))).scalars())
@mcp_router.post("/servers", response_model=McpOut, status_code=201)
async def create_server(
payload: McpIn, session: AsyncSession = Depends(get_session)
) -> McpServer:
if (await session.execute(select(McpServer).where(McpServer.name == payload.name))).scalar_one_or_none():
raise HTTPException(409, "同名 MCP server 已存在")
row = McpServer(**payload.model_dump())
session.add(row)
await session.commit()
await session.refresh(row)
return row
@mcp_router.patch("/servers/{server_id}", response_model=McpOut)
async def update_server(
server_id: str, payload: McpIn, session: AsyncSession = Depends(get_session)
) -> McpServer:
row = await session.get(McpServer, server_id)
if not row:
raise HTTPException(404, "MCP server 不存在")
for key, value in payload.model_dump().items():
setattr(row, key, value)
await session.commit()
await session.refresh(row)
return row
@mcp_router.delete("/servers/{server_id}", status_code=204)
async def delete_server(server_id: str, session: AsyncSession = Depends(get_session)) -> None:
row = await session.get(McpServer, server_id)
if not row:
raise HTTPException(404, "MCP server 不存在")
await session.delete(row)
await session.commit()
@mcp_router.post("/servers/{server_id}/probe")
async def probe_server(
server_id: str, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
row = await session.get(McpServer, server_id)
if not row:
raise HTTPException(404, "MCP server 不存在")
result = await mcp_manager.probe(row)
row.status = "ok" if result.get("ok") else "error"
row.last_error = result.get("error")
row.tools_cache = [t["name"] for t in result.get("tools", [])]
await session.commit()
return result
@mcp_router.post("/refresh")
async def refresh_mcp() -> dict[str, Any]:
return {"servers": await mcp_manager.refresh()}